From 153569f440f4e4258717f9a80c297ce120311a29 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 9 Sep 2026 15:10:25 +0200 Subject: [PATCH 01/24] Plan documenting the command placeholder contract (4.4.1) Add the ExecPlan for roadmap item 4.4.1, which adds a "Security and command interpolation" section to the README and settles the three contract questions that docs/formal-verification-methods-in-netsuke.md leaves open. Ground truth was established by reading src/ir/cmd_interpolate/ rather than by trusting existing prose, and three findings shaped the plan: - `$in` and `$out` ARE substituted in `script:` recipes though not in `command:` recipes, contradicting docs/developers-guide.md and docs/formal-verification-methods-in-netsuke.md; - the backtick guard is a whole-string odd-parity count, not a quoting-aware model, and does not defend against author-written command substitution; - `shlex::split` gates POSIX and Bash `command:` recipes only, never `script:` recipes and never PowerShell. The plan therefore spans five milestones: a new ADR recording the three decisions, the README section with two executable examples, correction of the two inaccurate internal documents, the six translated READMEs, and the roadmap closure. Because tests/documentation_examples/ requires a `tested-example` marker on every README fence and compares identifiers for exact set equality, adding the section is a test change as well as a prose change. That gives the item a genuine red-green cycle, which the verification plan uses to make each documented claim falsifiable. Co-Authored-By: Claude Opus 5 (1M context) --- ...-command-placeholder-contract-in-readme.md | 1203 +++++++++++++++++ 1 file changed, 1203 insertions(+) create mode 100644 docs/execplans/4-4-1-document-command-placeholder-contract-in-readme.md 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..6f758efb0 --- /dev/null +++ b/docs/execplans/4-4-1-document-command-placeholder-contract-in-readme.md @@ -0,0 +1,1203 @@ +# 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: DRAFT + +Revision 1. See `Revision note` at the foot of this document. + +## 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. + +Today that boundary is implemented, proved with Kani and Proptest, 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. +- Two of the README's claims are executable: a manifest that Netsuke accepts, + and a manifest that Netsuke rejects with a named diagnostic. Both 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-021-command-placeholder-contract.md`, records the three settled + contract decisions and their rationale, and the design document points at it. +- Two internal documents that currently misstate the contract are corrected. + +You can see it working by running `make test` and observing the new cases +`documentation_examples_tests::documented_example_registry_is_exhaustive`, +`readme_security_tests::documented_safe_placeholder_manifest_builds`, 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 (in scripts +only) `$in` and `$out`. *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 supported placeholder set differs by recipe kind.** +`find_substitution` (`src/ir/cmd_interpolate/mod.rs:261-266`) matches only +`INS_TOKEN` and `OUTS_TOKEN`, and is what `command:` recipes use. +`find_script_substitution` (`src/ir/cmd_interpolate/mod.rs:269-275`) also +matches `$in` and `$out` via `try_match_dollar_placeholder` +(`src/ir/cmd_interpolate/mod.rs:277-297`), which requires a non-alphanumeric, +non-underscore character on each side. So `$in` and `$out` **are** rewritten in +a `script:`, and **are not** rewritten in a `command:`. `$ins`, `$outs`, +`$input`, `$output`, and `$PATH` are never rewritten in either. Confirmed by +`src/ir/cmd_interpolate_tests.rs:42-50` +(`interpolate_command_preserves_dollar_prefixed_shell_variables`) and the +property test `dollar_prefixed_shell_variables_are_preserved` +(`src/ir/cmd_interpolate_property_tests.rs:55-66`). + +**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 exempt from both. `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`). +- Neither check sanitizes author-written shell text. A balanced backtick pair + containing text the author typed reaches the shell and is executed as command + substitution. The only exception is that `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:179`, +`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_recipe_shell.rs:282-290`). The +dependency is pinned at `shlex = "2.0.1"` (`Cargo.toml:138`). + +### 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:3186-3201`, section **Command interpolation + contract**. States "Literal shell variables such as `$in`, `$out`, `$ins`, and + `$outs` remain unchanged". True for a `command:`; false for a `script:` + (Fact A). +- `docs/users-guide.md:1649-1718`, section **Review the safety boundary**. + User-facing and largely accurate; the README will link to it rather than + restate 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 `81d44f89`: + +- `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. +- `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-M2 -> README.md "Security and command interpolation" +RM-4.4.1.b / FV-CPC-Q1 -> D1 -> EP-M1 (ADR-021 §Placeholders) -> EP-M2 -> tests::readme_security::documented_safe_placeholder_manifest_builds +RM-4.4.1.c / FV-CPC-Q2 -> D2 -> EP-M1 (ADR-021 §Backticks) -> EP-M2 -> tests::readme_security::documented_backtick_manifest_is_rejected +RM-4.4.1.d / FV-CPC-Q3 -> D3 -> EP-M1 (ADR-021 §shlex guard) -> EP-M2 -> tests::readme_security::documented_backtick_manifest_is_rejected +Fact A -> EP-M3 -> docs/developers-guide.md "Command interpolation contract" +Fact A -> EP-M3 -> docs/formal-verification-methods-in-netsuke.md FV-CPC +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-M3. 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-M2 → 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. **Do not weaken `docs/users-guide.md`.** The README summarizes and links; + the users' guide remains the detailed reference. + +## Tolerances (exception triggers) + +Stop and escalate when any threshold is reached. Do not work around them. + +- **Scope.** More than fourteen files changed, or more than 700 net added + lines across the whole plan. Expected: eleven files (`README.md`, six + translations, `docs/adr-021-…`, `docs/contents.md`, + `docs/developers-guide.md`, `docs/formal-verification-methods-in-netsuke.md`, + `docs/netsuke-design.md`, `docs/roadmap.md`, + `tests/documentation_examples_tests.rs`, and one new test file) — thirteen + paths. +- **Production code.** Any change to `src/` beyond the two doc-comment + corrections 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 two 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-Q2` or `FV-CPC-Q3` could reasonably + be settled the other way and the choice changes what the README promises. + +## 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: the two new README examples make the test suite slower or flaky.** + The e2e harness needs `ninja` on `PATH` and skips when absent + (`tests/documentation_examples_e2e_tests.rs:46-50`). Severity: low. + Likelihood: low. Mitigation: the rejection example needs no build at all — it + fails during IR lowering, before Ninja is invoked — so it can be a plain + integration test with no external tool. Only the accepting example touches + Ninja, and it reuses the existing skip guard. + +- **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. + +- **Risk: the ADR number collides.** `docs/` already contains duplicate + numbers at 003, 004, and 014. Severity: low. Likelihood: low. Mitigation: the + highest existing number is 020 + (`docs/adr-020-release-admission-observability.md`). Use 021 and re-run + `ls docs/adr-*` immediately before creating the file, in case another branch + has landed one. + +## 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 two 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: `cargo nextest run --test readme_security_tests`. Red before the + README section exists, because + `documented_example("readme-safe-placeholder- manifest")` 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 `documented_example_registry_is_exhaustive` also passes. +- 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. A negative control: + change the example's `{{ ins }}` to `{{ inputs }}` and the build must fail + because the marker is not recognized. Run that control once by hand and + record the transcript in `Artefacts and notes`; do not commit it. + +**`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 hand-written 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:179`. +- 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 and is currently + documented nowhere. A single example per branch is decisive because the two + code paths are distinct functions with no shared guard + (`src/ir/cmd_interpolate/mod.rs:189-212`). +- 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 10. +- Evidence: the command prints `7` identical counts and a matching level + sequence. +- Non-vacuity: the check compares the level sequence, not just the count, so + swapping an `##` for a `###` is caught. + +**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-021-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 — 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. It contains two marked, executable fenced + examples. `tests/documentation_examples_tests.rs` registers the two new + identifiers. `tests/readme_security_tests.rs` exists and discharges + `OBL-PLACEHOLDERS`, `OBL-BACKTICK-REJECT`, and `OBL-SHLEX-SCOPE`. The + existing safety paragraph in `## Release and development status` is reduced + to a one-line cross-reference so the two do not contradict each other. +- Requirements discharged: `RM-4.4.1.a`, `.b`, `.c`, `.d`. +- Acceptance evidence: `make check-fmt`, `make markdownlint`, `make lint`, + `make test` all pass. The three new tests fail before the README section + exists and pass after. Both negative controls behave as `Verification plan` + requires. +- Conformance check: every claim traces to a cited line or a new test; the + section leads with the boundary rather than the reassurance; `D1`–`D3` in the + ADR and the README prose agree word-for-word on the three contested points. +- Recovery: revert the commit. `README.md` and the two test files are the only + changes; nothing else depends on them. +- Remaining gaps: two internal documents still misstate Fact A; 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-M3 — internal documents corrected + +- Outcome: `docs/developers-guide.md` §*Command interpolation contract* states + the per-recipe-kind placeholder set correctly. + `docs/formal-verification-methods-in-netsuke.md` §*Command placeholder + contract* records that its three questions are now answered and points at + ADR-021; §*Kani for command interpolation* is corrected the same way; the + `[^8]` footnote path is corrected to `src/ir/cmd_interpolate/mod.rs`. + `docs/netsuke-design.md` is checked and corrected only if it also misstates + Fact A. Where a doc comment in `src/ir/cmd_interpolate/mod.rs` is itself + inaccurate about scripts, correct the comment text only — no code. +- Requirements advanced: Fact A consistency; `RM-4.4.1` completeness. +- Acceptance evidence: `grep -n '\$in' docs/*.md src/ir/cmd_interpolate/mod.rs` + shows no remaining statement that `$in` is universally left unchanged. + `make check-fmt`, `make markdownlint`, `make lint`, `make test` pass — + `make lint` matters here because a doc-comment edit recompiles. +- Conformance check: no behaviour changed; `git diff --stat src/` shows only + comment lines; the corrected statements agree with EP-M2's README text. +- Recovery: revert the commit; the edits are independent of EP-M2. +- Remaining gaps: translations. +- Compatibility decision: none. + +### 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 10 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-M2 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-021 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 11, 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. The recommended answers, to be confirmed at the +`logisphere-design-review` checkpoint below: + +- **`D1` (answers `FV-CPC-Q1`).** These are the only supported placeholders: + `{{ ins }}` and `{{ outs }}` in both `command:` and `script:` recipes, and + `$in` and `$out` in `script:` recipes only, matched at identifier boundaries. + Everything else is literal text for the shell. Netsuke does not expose + Ninja's own `$in` / `$out` rule variables, because it bakes resolved paths + into each content-hashed rule. +- **`D2` (answers `FV-CPC-Q2`).** The backtick handling is a **deliberate, + narrow structural guard over Netsuke-owned lowering**, not a model of shell + command substitution and not a subset that a future release is committed to + widening. Netsuke guarantees exactly two things: a Netsuke marker never + survives into a backtick or `$( … )` region, and a `command:` recipe whose + substituted text has an odd total backtick count is rejected. Netsuke + guarantees nothing about author-written backticks that contain no marker; + those reach the shell and execute. PowerShell is outside both checks because + its backtick is an escape character. +- **`D3` (answers `FV-CPC-Q3`).** `shlex::split` **is** part of the semantic + 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 is **not** a stability + commitment about the precise accepted set, because that set is a third-party + approximation of POSIX (`AX-SHLEX`) and may shift with the crate. It does not + apply to `script:` recipes or to PowerShell, and Netsuke never executes the + tokens it produces. + +Go/no-go: run the `logisphere-design-review` skill over `D1`–`D3` and the draft +section outline. Proceed only if the review does not find that the wording +could be read as a guarantee Netsuke does not make. + +### Stage B — red + +Add `tests/readme_security_tests.rs` with the five cases named in +`Verification plan`, referencing the two documented-example identifiers that do +not yet exist. 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 two identifiers to `EXPECTED_EXAMPLE_IDS` *before* adding the +README fences, and observe `documented_example_registry_is_exhaustive` fail +with a registry-drift message naming exactly the two missing identifiers. That +is a second, sharper red signal. + +### Stage C — green + +Write the README section (EP-M2). The section's shape, in order: + +1. A one-paragraph framing: a `Netsukefile` executes commands, so treat it as + you would a `Makefile`; Netsuke narrows some mistakes but is not a sandbox + and does not sanitize shell text you write. +2. **What Netsuke rewrites** — the `D1` placeholder table or bullet list, split + by recipe kind, with the accepted example fence. +3. **What Netsuke leaves alone** — `$PATH`, `$ins`, `$outs`, `$input`, + `$output`, arbitrary Jinja values, and handwritten shell fragments. +4. **What Netsuke quotes** — only its own path substitutions, via + `shell-quote`, encoded for the surrounding quote context. +5. **Backticks and command substitution** — the `D2` boundary, with the + rejected example fence and the exact diagnostic text. +6. **The `shlex` guard** — the `D3` statement, naming the routes it covers and + the routes it does not. +7. A closing pointer to `docs/users-guide.md#review-the-safety-boundary` and to + ADR-021. + +Then reduce the existing safety paragraph in +`## Release and development status` to a single cross-referencing sentence. + +Run the suite again and observe green. + +### Stage D — correct, translate, refactor, and validate + +EP-M3, 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 call `try_match_dollar_placeholder`, and the + Fluent line + `ir.invalid_command = Invalid command interpolation: { $snippet }.` + +3. Confirm the next free ADR number. + + ```sh + ls docs/adr-*.md | sed 's/.*adr-0*\([0-9]*\).*/\1/' | sort -n | tail -1 + ``` + + Expect `20`. If it is higher, use the next number and update every reference + in this plan. + +4. Write `docs/adr-021-command-placeholder-contract.md`, add its entry to + `docs/contents.md` under `## Decision records` immediately above the ADR-020 + entry, and add the design-document reference. Commit as EP-M1. + + ```sh + 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. Add `tests/readme_security_tests.rs` and the two identifiers to + `EXPECTED_EXAMPLE_IDS`. Observe red. + + ```sh + cargo nextest run --test readme_security_tests --test documentation_examples_tests \ + 2>&1 | tee /tmp/red-netsuke-$(git branch --show-current).out + ``` + + Expect failures naming `readme-safe-placeholder-manifest` and + `readme-backtick-rejection-manifest`, and a registry-drift failure listing + exactly those two identifiers. Record the transcript in + `Artefacts and notes`. + +6. Write the README section with both marked fences. Observe green. + + ```sh + cargo nextest run --test readme_security_tests --test documentation_examples_tests \ + 2>&1 | tee /tmp/green-netsuke-$(git branch --show-current).out + ``` + +7. Run the two negative controls by hand, record both transcripts, and revert + each afterwards. + + ```sh + # Control 1: break the accepted example's marker and expect a build failure. + # Edit README.md, change {{ ins }} to {{ inputs }} in the safe example, then: + cargo nextest run --test readme_security_tests + git checkout -- README.md + + # Control 2: seed the parity fault and expect the odd-backtick case to fail. + git apply docs/verification/mutations/ir__cmd_interpolate__property_tests__substituted_odd_backticks_are_rejected.patch + cargo nextest run --test readme_security_tests + git apply -R docs/verification/mutations/ir__cmd_interpolate__property_tests__substituted_odd_backticks_are_rejected.patch + ``` + +8. Reduce the duplicated safety paragraph in + `## Release and development status` to a cross-reference. Run the full gates + and commit as EP-M2. + +9. Correct the internal documents and any inaccurate doc comment. Commit as + EP-M3. + + ```sh + grep -rn 'literal .\$in\|\$in. and .\$out. remain' docs/ src/ + ``` + + Expect no remaining unqualified claim that `$in` is always left unchanged. + +10. Translate into the six READMEs, 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,3\} ' "$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,3\} ' "$f" | tr -d ' \n' + echo + done + ``` + + Expect `13` for every file, and an identical `#`-level sequence on every + line. + +11. 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. + + ```sh + 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 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 }}`, + `{{ outs }}`, and the script-only `$in` and `$out`. It says plainly that a + backtick pair the author wrote is executed by the shell. It says which recipe + kinds and which shells the `shlex` gate covers. +- Copy the section's rejected example into a `Netsukefile` and run `netsuke`. + 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: `make test` passes. The five new cases in + `tests/readme_security_tests.rs` pass, and each failed before the README + section existed. +- Verification: `OBL-PLACEHOLDERS`, `OBL-BACKTICK-REJECT`, and + `OBL-SHLEX-SCOPE` are discharged with both negative-control transcripts + recorded. `OBL-STRUCTURAL-PARITY` is discharged by the step-10 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, which + skips when `ninja` is absent. +- 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-M2 conformance check. + +Quality method: the gate sequence in `Concrete steps` step 11, 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 two 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}; +``` + +The cases it must define, by name: + +- `documented_safe_placeholder_manifest_builds` +- `documented_backtick_manifest_is_rejected` +- `balanced_author_backticks_are_accepted` +- `odd_backtick_count_without_markers_is_rejected` +- `shlex_gate_applies_to_commands_not_scripts` + +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-safe-placeholder-manifest` + +New document `docs/adr-021-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 + +- [ ] EP-M1 — ADR-021 written, indexed, and referenced from the design + document. +- [ ] EP-M2 — README section and executable examples; red observed before + green. +- [ ] EP-M3 — `docs/developers-guide.md`, + `docs/formal-verification-methods-in-netsuke.md`, and any inaccurate doc + comment corrected. +- [ ] EP-M4 — six translated READMEs regain structural parity. +- [ ] EP-M5 — roadmap 4.4.1 marked done; full gate sequence green. + +## Surprises & discoveries + +- Observation: `$in` and `$out` **are** 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-M3 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-M3. + +## Decision log + +- Decision `D1`: 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. + Awaiting approval. + +- 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. Awaiting approval. + +- 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. Awaiting approval. + +- 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. The boundary is correct; the plan documents it. See + `Conformance basis`. Date/Author: 2026-09-09, planning agent. + +- Decision `D-NO-PARITY-GATE`: structural parity of the README family is + verified by a recorded command, not by a committed test. Rationale: a + committed parity test would be a new repository-wide policy gate firing on + unrelated future work, which exceeds 4.4.1's mandate. Escalate if a reviewer + wants the gate. Date/Author: 2026-09-09, planning 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. + +## Outcomes & retrospective + +Not yet started. To be completed at EP-M5. + +Before setting this plan to `COMPLETE`, reconcile each entry in +`Surprises & discoveries` against the artefacts in `Conformance basis`: confirm +that EP-M3 corrected the Fact A misstatements in both documents and the `[^8]` +footnote, that `docs/formal-verification-methods-in-netsuke.md` records +`FV-CPC-Q1` through `FV-CPC-Q3` as answered with a pointer to ADR-021, and that +the 4.2.3 status discrepancy is either resolved elsewhere or recorded as +knowingly deferred. + +## Artefacts and notes + +To be populated during implementation. At minimum, retain: + +- the red transcript from `Concrete steps` step 5, showing both + missing-identifier failures and the registry-drift failure; +- the green transcript from step 6; +- both negative-control transcripts from step 7; +- the parity output from step 10; +- the gate summary from step 11, 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-021 as evidence for why `D3` declines to make the + accepted set a stability commitment. + +## Revision note + +Revision 1 (2026-09-09). Initial draft. Establishes the three contract decisions +`D1`–`D3` from a direct reading of `src/ir/cmd_interpolate/`, scopes the work +to five milestones covering the ADR, the README section with executable +examples, correction of two inaccurate internal documents, six translations, +and the roadmap closure. Remaining work: all of it; the plan awaits approval +before any implementation begins. From b90346071ea65b4495ef7076e842b5f06c605e57 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 9 Sep 2026 15:23:15 +0200 Subject: [PATCH 02/24] Revise the 4.4.1 plan after design review Fold a six-lens design review into revision 2 of the ExecPlan. The review found four factual errors in revision 1 and two obligations that should have existed and did not. Blocking corrections: - EP-M2 (document corrections) now precedes the README milestone and covers docs/users-guide.md:1697,1713, which also contradict the script-recipe placeholder behaviour. Revision 1 would have shipped a README that contradicted the very page it links to. - The negative controls now run after the README is committed and restore by file copy. Revision 1 ran them first and restored with `git checkout -- README.md`, which would have deleted the section it had just written. - OBL-PLACEHOLDERS' negative control was non-discriminating: UndefinedBehavior::Strict makes an undefined `{{ inputs }}` fail during rendering, before marker recognition is reached. - Two obligations added. OBL-RECIPE-KIND covers the plan's own headline finding, and OBL-QUOTING covers the section's only positive security promise. Neither had any test in revision 1. Factual corrections: assert_shell_command is in src/ninja_gen/mod.rs, not src/ninja_gen_recipe_shell.rs; `shlex = "2.0.1"` is a caret requirement, not a pin; the registry test is every_documented_fence_has_a_known_unique_identifier; and docs/netsuke-design.md already states the contract correctly and must not be touched. Rewording: D2 now promises the marker invariant and the backtick 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. 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 to 18 files / 1200 lines, because the review showed the original would fire on the plan's own expected path. Co-Authored-By: Claude Opus 5 (1M context) --- ...-command-placeholder-contract-in-readme.md | 830 +++++++++++++----- 1 file changed, 624 insertions(+), 206 deletions(-) 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 index 6f758efb0..a9df0e3cd 100644 --- 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 @@ -40,11 +40,16 @@ After this change: - A new Architectural Decision Record, `docs/adr-021-command-placeholder-contract.md`, records the three settled contract decisions and their rationale, and the design document points at it. -- Two internal documents that currently misstate the contract are corrected. - -You can see it working by running `make test` and observing the new cases -`documentation_examples_tests::documented_example_registry_is_exhaustive`, -`readme_security_tests::documented_safe_placeholder_manifest_builds`, and +- Three documents that currently misstate the contract are corrected — + `docs/developers-guide.md`, `docs/users-guide.md`, and + `docs/formal-verification-methods-in-netsuke.md`. All three say, without + qualifying it to command recipes, that literal `$in` and `$out` are left + alone. In a `script:` recipe they are not. + +You can see it working by running `NETSUKE_REQUIRE_NINJA=1 make test` and +observing the new cases +`readme_security_tests::placeholder_rewriting_differs_by_recipe_kind`, +`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. @@ -146,8 +151,16 @@ 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_recipe_shell.rs:282-290`). The -dependency is pinned at `shlex = "2.0.1"` (`Cargo.toml:138`). +`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 @@ -299,12 +312,15 @@ at commit `81d44f89`: Trace links: ```plaintext -RM-4.4.1.a -> EP-M2 -> README.md "Security and command interpolation" -RM-4.4.1.b / FV-CPC-Q1 -> D1 -> EP-M1 (ADR-021 §Placeholders) -> EP-M2 -> tests::readme_security::documented_safe_placeholder_manifest_builds -RM-4.4.1.c / FV-CPC-Q2 -> D2 -> EP-M1 (ADR-021 §Backticks) -> EP-M2 -> tests::readme_security::documented_backtick_manifest_is_rejected -RM-4.4.1.d / FV-CPC-Q3 -> D3 -> EP-M1 (ADR-021 §shlex guard) -> EP-M2 -> tests::readme_security::documented_backtick_manifest_is_rejected -Fact A -> EP-M3 -> docs/developers-guide.md "Command interpolation contract" -Fact A -> EP-M3 -> docs/formal-verification-methods-in-netsuke.md FV-CPC +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-021 §Placeholders) -> EP-M3 -> tests::readme_security::documented_safe_placeholder_manifest_builds +RM-4.4.1.c / FV-CPC-Q2 -> D2 -> EP-M1 (ADR-021 §Backticks) -> EP-M3 -> tests::readme_security::documented_backtick_manifest_is_rejected +RM-4.4.1.d / FV-CPC-Q3 -> D3 -> EP-M1 (ADR-021 §shlex guard) -> EP-M3 -> tests::readme_security::documented_backtick_manifest_is_rejected +RM-4.4.1.b / Fact A -> OBL-RECIPE-KIND -> tests::readme_security::placeholder_rewriting_differs_by_recipe_kind +Fact A -> EP-M2 -> docs/developers-guide.md "Command interpolation contract" +Fact A -> EP-M2 -> docs/users-guide.md "Review the safety boundary" (:1697, :1713) +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 ``` @@ -325,7 +341,7 @@ 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-M3. This is + 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. @@ -340,7 +356,7 @@ workaround. 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-M2 → EP-M4 window. + 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 @@ -350,35 +366,55 @@ workaround. `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. **Do not weaken `docs/users-guide.md`.** The README summarizes and links; - the users' guide remains the detailed reference. +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-021, 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 fourteen files changed, or more than 700 net added - lines across the whole plan. Expected: eleven files (`README.md`, six - translations, `docs/adr-021-…`, `docs/contents.md`, - `docs/developers-guide.md`, `docs/formal-verification-methods-in-netsuke.md`, - `docs/netsuke-design.md`, `docs/roadmap.md`, - `tests/documentation_examples_tests.rs`, and one new test file) — thirteen - paths. -- **Production code.** Any change to `src/` beyond the two doc-comment - corrections named in EP-M3. Immediate stop. +- **Scope.** More than eighteen files changed, or more than 1200 net added + lines across the whole plan. The expected set is sixteen paths: `README.md`; + the six translations; `docs/adr-021-command-placeholder-contract.md`; + `docs/contents.md`; `docs/developers-guide.md`; + `docs/formal-verification-methods-in-netsuke.md`; `docs/users-guide.md`; + `docs/repository-layout.md`; `docs/roadmap.md`; + `tests/documentation_examples_tests.rs`; and one new test file. This plan + document itself is a seventeenth, and `src/ir/cmd_interpolate/mod.rs` a + possible eighteenth if its doc comment needs correcting. + + The line ceiling is deliberately generous because the README section is + written seven times. A realistic estimate is 70-100 lines of section prose + per edition (490-700 in total), 150-250 for the ADR, 150-220 for the test + file, and 30-50 for the index, roadmap, and correction edits: 820-1220. A + tighter ceiling would fire on the plan's own expected path, which trains an + implementer to ignore the tolerance and destroys its value for the cases that + matter. +- **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 two new identifiers. + `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-Q2` or `FV-CPC-Q3` could reasonably - be settled the other way and the choice changes what the README promises. +- **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` carries a known live instance of this: see the + `D1-LEGACY` note in `Decision log`. ## Risks @@ -493,24 +529,91 @@ Assumptions relied upon, not verified here: 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 two fenced examples in the new README section, extracted at run - time by `documented_example`. +- 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: `cargo nextest run --test readme_security_tests`. Red before the - README section exists, because - `documented_example("readme-safe-placeholder- manifest")` returns an error - whose context is +- 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 `documented_example_registry_is_exhaustive` also passes. + 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. A negative control: - change the example's `{{ ins }}` to `{{ inputs }}` and the build must fail - because the marker is not recognized. Run that control once by hand and - record the transcript in `Artefacts and notes`; do not commit it. + 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 **`command:`** + example and require it to survive into the generated Ninja verbatim, as + `$$in`. That fails only if the recipe-kind asymmetry breaks, 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-RECIPE-KIND` — the placeholder set really does differ by recipe kind.** + +- Statement: `$in` and `$out` are rewritten to paths in a `script:` recipe and + left as literal shell text in a `command:` recipe; `$ins`, `$outs`, `$input`, + and `$output` are left alone in both. +- Method: parameterized `rstest` over the (placeholder, recipe kind) matrix, + asserting on generated Ninja text. +- Rationale: this is the plan's headline discovery, the reason three documents + are wrong today, and the most surprising claim the README will make. Shipping + it with no executable counterpart would reproduce exactly the failure mode + this section exists to prevent: a documentation claim nobody can falsify. + Absent from revision 1; added at design review. +- Domain: the ten cells formed by `{{ ins }}`, `{{ outs }}`, `$in`, `$out`, + and `$ins`, each crossed with `command:` and `script:`. +- Artefact: `tests/readme_security_tests.rs`, case + `placeholder_rewriting_differs_by_recipe_kind`. +- Evidence: `cargo nextest run --test readme_security_tests`. Discharged when + every cell matches the README's table. +- Non-vacuity: both arms of each row are asserted, so the test fails against an + implementation that rewrote `$in` in both recipe kinds **and** against one + that rewrote it in neither. A single-arm test would pass against the + currently documented — and wrong — uniform behaviour, which is precisely how + the existing misstatement survived. As a seeded fault, remove the two + `try_match_dollar_placeholder` calls from `find_script_substitution` + (`src/ir/cmd_interpolate/mod.rs:269-275`); the `script:` rows must fail. + 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.** @@ -582,11 +685,16 @@ boundary.** 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 10. +- Artefact: the command in `Concrete steps` step 9. - Evidence: the command prints `7` identical counts and a matching level sequence. -- Non-vacuity: the check compares the level sequence, not just the count, so - swapping an `##` for a `###` is caught. +- 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 @@ -614,54 +722,89 @@ new evidence. - Remaining gaps: the README still says nothing. - Compatibility decision: none. A new document has no consumers. -### EP-M2 — the README states the contract, executably +### EP-M2 — internal documents corrected + +This milestone deliberately precedes the README section. Landing the README +first would create a plateau at which `README.md` and `docs/users-guide.md` +state opposite things about `$in` in a `script:`, and `D-SCOPE` already argues +that such a state is exactly the ambiguity 4.4.1 exists to remove. The two +milestones are independent, so ordering costs nothing and removes the +contradiction window entirely. + +- Outcome: three documents state the per-recipe-kind placeholder set + correctly. + - `docs/developers-guide.md:3187-3190` (§*Command interpolation contract*), + which currently says "Literal shell variables such as `$in`, `$out`, + `$ins`, and `$outs` remain unchanged" without qualifying it to command + recipes. + - `docs/users-guide.md:1697-1698`, which says "`{{ ins }}` and `{{ outs }}` + are the only Netsuke markers for input and output paths", and + `docs/users-guide.md:1713-1715`, which tells authors to replace `$in` and + `$out` without saying that the `script:` forms still resolve. + - `docs/formal-verification-methods-in-netsuke.md:265-266` (§*Command + placeholder contract*) and `:64-66` (§*Kani for command interpolation*), + both of which say literal `$in` and `$out` "remain shell variables" + unqualified. Also record in §*Command placeholder contract* that + `FV-CPC-Q1` through `FV-CPC-Q3` are now answered, pointing at ADR-021, and + correct the `[^8]` footnote path from `src/ir/cmd_interpolate.rs` to + `src/ir/cmd_interpolate/mod.rs`. + + `docs/netsuke-design.md:290-291` already states this correctly — "standalone + `$in` and `$out` resolve only in scripts" — and must **not** be changed. It + is the canonical wording the other three should converge on. Where a doc + comment in `src/ir/cmd_interpolate/mod.rs` is itself inaccurate about + scripts, correct the comment text only; no code. + +- 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`. +- 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 three corrected statements agree with each other and with + `docs/netsuke-design.md:290-291`; no historical document (`docs/archive/`, any + `docs/adr-0*.md`, 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. It contains two marked, executable fenced - examples. `tests/documentation_examples_tests.rs` registers the two new - identifiers. `tests/readme_security_tests.rs` exists and discharges - `OBL-PLACEHOLDERS`, `OBL-BACKTICK-REJECT`, and `OBL-SHLEX-SCOPE`. The - existing safety paragraph in `## Release and development status` is reduced - to a one-line cross-reference so the two do not contradict each other. + 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-RECIPE-KIND`, `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`, - `make test` all pass. The three new tests fail before the README section - exists and pass after. Both negative controls behave as `Verification plan` - requires. +- 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 - section leads with the boundary rather than the reassurance; `D1`–`D3` in the - ADR and the README prose agree word-for-word on the three contested points. + 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-021 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: two internal documents still misstate Fact A; six - translations lack the section. +- 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-M3 — internal documents corrected - -- Outcome: `docs/developers-guide.md` §*Command interpolation contract* states - the per-recipe-kind placeholder set correctly. - `docs/formal-verification-methods-in-netsuke.md` §*Command placeholder - contract* records that its three questions are now answered and points at - ADR-021; §*Kani for command interpolation* is corrected the same way; the - `[^8]` footnote path is corrected to `src/ir/cmd_interpolate/mod.rs`. - `docs/netsuke-design.md` is checked and corrected only if it also misstates - Fact A. Where a doc comment in `src/ir/cmd_interpolate/mod.rs` is itself - inaccurate about scripts, correct the comment text only — no code. -- Requirements advanced: Fact A consistency; `RM-4.4.1` completeness. -- Acceptance evidence: `grep -n '\$in' docs/*.md src/ir/cmd_interpolate/mod.rs` - shows no remaining statement that `$in` is universally left unchanged. - `make check-fmt`, `make markdownlint`, `make lint`, `make test` pass — - `make lint` matters here because a doc-comment edit recompiles. -- Conformance check: no behaviour changed; `git diff --stat src/` shows only - comment lines; the corrected statements agree with EP-M2's README text. -- Recovery: revert the commit; the edits are independent of EP-M2. -- Remaining gaps: translations. -- Compatibility decision: none. - ### EP-M4 — the translated READMEs regain parity - Outcome: all six translated READMEs carry the equivalent section in the same @@ -669,7 +812,7 @@ new evidence. 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 10 reports +- 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. @@ -677,7 +820,7 @@ new evidence. 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-M2 did not depend on the translations. + EP-M3 did not depend on the translations. - Remaining gaps: the roadmap entry is still open. - Compatibility decision: none. @@ -688,8 +831,8 @@ new evidence. recording that the contract was settled in ADR-021 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 11, with - each log file retained. +- 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. @@ -706,80 +849,156 @@ 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. The recommended answers, to be confirmed at the -`logisphere-design-review` checkpoint below: - -- **`D1` (answers `FV-CPC-Q1`).** These are the only supported placeholders: - `{{ ins }}` and `{{ outs }}` in both `command:` and `script:` recipes, and - `$in` and `$out` in `script:` recipes only, matched at identifier boundaries. - Everything else is literal text for the shell. Netsuke does not expose - Ninja's own `$in` / `$out` rule variables, because it bakes resolved paths - into each content-hashed rule. -- **`D2` (answers `FV-CPC-Q2`).** The backtick handling is a **deliberate, - narrow structural guard over Netsuke-owned lowering**, not a model of shell - command substitution and not a subset that a future release is committed to - widening. Netsuke guarantees exactly two things: a Netsuke marker never - survives into a backtick or `$( … )` region, and a `command:` recipe whose - substituted text has an odd total backtick count is rejected. Netsuke - guarantees nothing about author-written backticks that contain no marker; - those reach the shell and execute. PowerShell is outside both checks because - its backtick is an escape character. -- **`D3` (answers `FV-CPC-Q3`).** `shlex::split` **is** part of the semantic - 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 is **not** a stability - commitment about the precise accepted set, because that set is a third-party - approximation of POSIX (`AX-SHLEX`) and may shift with the crate. It does not - apply to `script:` recipes or to PowerShell, and Netsuke never executes the - tokens it produces. - -Go/no-go: run the `logisphere-design-review` skill over `D1`–`D3` and the draft -section outline. Proceed only if the review does not find that the wording -could be read as a guarantee Netsuke does not make. +Then settle the three questions. These answers were revised at the +`logisphere-design-review` checkpoint; the wording below is what the README and +ADR-021 must say. + +- **`D1` (answers `FV-CPC-Q1`).** `{{ ins }}` and `{{ outs }}` are the + supported placeholders, in both `command:` and `script:` recipes. In + `script:` recipes Netsuke *additionally* rewrites bare `$in` and `$out`, at + identifier boundaries. Everything else — `$ins`, `$outs`, `$input`, `$output`, + `$PATH` — is literal text for the shell, in both recipe kinds. Netsuke does + not expose Ninja's own `$in` / `$out` rule variables, because it bakes + resolved paths into each content-hashed rule. + + The `script:` forms are documented as **retained legacy behaviour, not a + blessed feature**, and the README steers authors to `{{ ins }}` and + `{{ outs }}` in new manifests. Two consequences must be stated, because both + are foot-guns a reader would not predict: a script that legitimately writes + `in=foo; echo $in` has its own shell variable rewritten to input paths with + no diagnostic; and moving that text from `script:` to `command:` silently + changes its meaning, because `$in` there degrades to an empty shell variable. + See `D1-LEGACY` in `Decision log` for the open question this raises. + +- **`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 does not inspect backticks the author + wrote. A balanced pair is passed to the shell and executed as command + substitution. + - **Route scope.** PowerShell is outside both, because its backtick is an + escape character. The parity check is also `command:`-only; a `script:` + recipe gets the marker invariant but no parity check. State this, because + a reader who moves a recipe to `script:` otherwise loses a check silently. + +- **`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 five cases named in -`Verification plan`, referencing the two documented-example identifiers that do -not yet exist. Run the suite and observe each case failing with +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. Its first line must be `mod documentation_examples;` — 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 +`xfail`, and the missing-identifier error is already specific enough to prove the test fails for the intended reason. -Also add the two identifiers to `EXPECTED_EXAMPLE_IDS` *before* adding the -README fences, and observe `documented_example_registry_is_exhaustive` fail -with a registry-drift message naming exactly the two missing identifiers. That -is a second, sharper red signal. +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-M2). The section's shape, in order: - -1. A one-paragraph framing: a `Netsukefile` executes commands, so treat it as - you would a `Makefile`; Netsuke narrows some mistakes but is not a sandbox - and does not sanitize shell text you write. -2. **What Netsuke rewrites** — the `D1` placeholder table or bullet list, split - by recipe kind, with the accepted example fence. -3. **What Netsuke leaves alone** — `$PATH`, `$ins`, `$outs`, `$input`, - `$output`, arbitrary Jinja values, and handwritten shell fragments. -4. **What Netsuke quotes** — only its own path substitutions, via - `shell-quote`, encoded for the surrounding quote context. -5. **Backticks and command substitution** — the `D2` boundary, with the - rejected example fence and the exact diagnostic text. -6. **The `shlex` guard** — the `D3` statement, naming the routes it covers and - the routes it does not. -7. A closing pointer to `docs/users-guide.md#review-the-safety-boundary` and to - ADR-021. - -Then reduce the existing safety paragraph in -`## Release and development status` to a single cross-referencing sentence. +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 treat it as you would 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 }}`, `$in`, `$out`, and the inert `$ins`/`$outs`/ + `$input`/`$output` group; columns for `command:` and `script:`. Cells are + code identifiers and yes/no, which is what survives translation intact. Mark + the `$in` / `$out` row as retained legacy, with the shadowing caveat from + `D1`. 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-021. + +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-M3, then EP-M4, then EP-M5. Each is a separate commit. After each, run the +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. @@ -831,7 +1050,26 @@ Run everything from the repository root, make markdownlint 2>&1 | tee /tmp/markdownlint-netsuke-$(git branch --show-current).out ``` -5. Add `tests/readme_security_tests.rs` and the two identifiers to +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. + + ```sh + grep -rn -e 'only Netsuke markers' -e 'remain unchanged' \ + -e 'remain shell variables' -e 'remain literal shell variables' \ + README.md docs/*.md src/ir/cmd_interpolate/ \ + | grep -v '^docs/adr-' | grep -v '^docs/archive/' + ``` + + Every surviving hit must be qualified by recipe kind, or be + `docs/netsuke-design.md:290-291`, which is already correct and must not be + changed. Revision 1's grep (`'literal .\$in\|\$in. and .\$out. remain'`) + matched neither `docs/users-guide.md:1697`, whose claim wraps across a line + break, nor the developers-guide phrasing, and it did 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. ```sh @@ -839,66 +1077,92 @@ Run everything from the repository root, 2>&1 | tee /tmp/red-netsuke-$(git branch --show-current).out ``` - Expect failures naming `readme-safe-placeholder-manifest` and - `readme-backtick-rejection-manifest`, and a registry-drift failure listing - exactly those two identifiers. Record the transcript in - `Artefacts and notes`. + 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`. -6. Write the README section with both marked fences. Observe green. +7. Write the README section with all three marked fences. Observe green, then + run the full gates and commit as EP-M3. ```sh - cargo nextest run --test readme_security_tests --test documentation_examples_tests \ + 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 ``` -7. Run the two negative controls by hand, record both transcripts, and revert - each afterwards. + `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 - # Control 1: break the accepted example's marker and expect a build failure. - # Edit README.md, change {{ ins }} to {{ inputs }} in the safe example, then: + 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-RECIPE-KIND): remove the two try_match_dollar_placeholder + # calls from find_script_substitution; the script rows must fail. + cp src/ir/cmd_interpolate/mod.rs /tmp/cmd-interpolate-control.rs + # ...edit, then: cargo nextest run --test readme_security_tests - git checkout -- README.md + cp /tmp/cmd-interpolate-control.rs src/ir/cmd_interpolate/mod.rs - # Control 2: seed the parity fault and expect the odd-backtick case to fail. - git apply docs/verification/mutations/ir__cmd_interpolate__property_tests__substituted_odd_backticks_are_rejected.patch + # Control 3 (OBL-BACKTICK-REJECT): seed the stored parity fault. + git apply "$MUT" cargo nextest run --test readme_security_tests - git apply -R docs/verification/mutations/ir__cmd_interpolate__property_tests__substituted_odd_backticks_are_rejected.patch + git apply -R "$MUT" + git status --porcelain ``` -8. Reduce the duplicated safety paragraph in - `## Release and development status` to a cross-reference. Run the full gates - and commit as EP-M2. + 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. Correct the internal documents and any inaccurate doc comment. Commit as - EP-M3. +9. Translate into the six READMEs, add the recurring-obligation bullet to + `docs/repository-layout.md`, then check parity. Commit as EP-M4. ```sh - grep -rn 'literal .\$in\|\$in. and .\$out. remain' docs/ src/ + 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 no remaining unqualified claim that `$in` is always left unchanged. + 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`. -10. Translate into the six READMEs, then check parity. Commit as EP-M4. + 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. - ```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,3\} ' "$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,3\} ' "$f" | tr -d ' \n' - echo - done - ``` - - Expect `13` for every file, and an identical `#`-level sequence on every - line. - -11. Mark roadmap 4.4.1 done and run the full gate sequence. Commit as EP-M5. +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. @@ -942,9 +1206,9 @@ Quality criteria — what "done" means: skips when `ninja` is absent. - 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-M2 conformance check. + EP-M3 conformance check. -Quality method: the gate sequence in `Concrete steps` step 11, delegated to +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 @@ -976,14 +1240,27 @@ mod documentation_examples; use documentation_examples::{documented_example, manifest_workspace}; ``` +Its first line must be `mod documentation_examples;`. That declaration 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` +- `placeholder_rewriting_differs_by_recipe_kind` +- `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 @@ -994,8 +1271,13 @@ New identifiers added to `EXPECTED_EXAMPLE_IDS` in `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-021-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 @@ -1012,12 +1294,13 @@ reads naturally. - [ ] EP-M1 — ADR-021 written, indexed, and referenced from the design document. -- [ ] EP-M2 — README section and executable examples; red observed before - green. -- [ ] EP-M3 — `docs/developers-guide.md`, +- [ ] EP-M2 — `docs/developers-guide.md`, `docs/users-guide.md`, `docs/formal-verification-methods-in-netsuke.md`, and any inaccurate doc - comment corrected. -- [ ] EP-M4 — six translated READMEs regain structural parity. + comment corrected. Precedes the README deliberately. +- [ ] EP-M3 — README section and three executable examples; red observed + before green; three negative controls recorded after the commit. +- [ ] EP-M4 — six translated READMEs regain structural parity; + `docs/repository-layout.md` records the recurring obligation. - [ ] EP-M5 — roadmap 4.4.1 marked done; full gate sequence green. ## Surprises & discoveries @@ -1027,7 +1310,7 @@ reads naturally. `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-M3 exists because of this. + `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. @@ -1062,7 +1345,52 @@ reads naturally. - 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-M3. + 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: `docs/users-guide.md:1697-1698` and `:1713-1715` also + contradict 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. ## Decision log @@ -1135,14 +1463,77 @@ reads naturally. - 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. The boundary is correct; the plan documents it. See - `Conformance basis`. Date/Author: 2026-09-09, planning agent. + 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-021 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 - verified by a recorded command, not by a committed test. Rationale: a - committed parity test would be a new repository-wide policy gate firing on - unrelated future work, which exceeds 4.4.1's mandate. Escalate if a reviewer - wants the gate. Date/Author: 2026-09-09, planning agent. + 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-LEGACY`: the `script:`-only `$in` / `$out` forms are documented + as retained legacy behaviour, with an explicit shadowing warning and a steer + towards `{{ ins }}` / `{{ outs }}`, rather than as a blessed feature. + Rationale: the evidence points both ways and the plan must not silently pick + one. *Intended:* three `#[cfg(unix)]` end-to-end cases exercise the quoted + contexts (`tests/ninja_dollar_escaping_tests.rs:361-393`). *Vestigial:* the + users' guide already tells authors to migrate away + (`docs/users-guide.md:1713-1715`); the Ninja-escaping case covering the + command-recipe pass-through is named `legacy_marker_aliases` + (`tests/ninja_dollar_escaping_tests.rs:207`); and the Kani harness doc + comment asserts "Literal `$in` and `$out` must remain shell text" + (`src/ir/cmd_interpolate/verification.rs:4`). Documenting current behaviour + plus a direction of travel is honest under either reading and commits the + project to neither. + + **This decision needs the owner's confirmation.** If the intent is that these + forms be withdrawn before 1.0, the README should say so. If they are + supported indefinitely, the shadowing hazard deserves a diagnostic rather + than a footnote — and that would be a code change outside this item. + Date/Author: 2026-09-09, design review. Awaiting approval. - 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 @@ -1156,7 +1547,7 @@ Not yet started. To be completed at EP-M5. Before setting this plan to `COMPLETE`, reconcile each entry in `Surprises & discoveries` against the artefacts in `Conformance basis`: confirm -that EP-M3 corrected the Fact A misstatements in both documents and the `[^8]` +that EP-M2 corrected the Fact A misstatements in both documents and the `[^8]` footnote, that `docs/formal-verification-methods-in-netsuke.md` records `FV-CPC-Q1` through `FV-CPC-Q3` as answered with a pointer to ADR-021, and that the 4.2.3 status discrepancy is either resolved elsewhere or recorded as @@ -1195,9 +1586,36 @@ Reference material gathered during planning, for the writer's use: ## Revision note -Revision 1 (2026-09-09). Initial draft. Establishes the three contract decisions -`D1`–`D3` from a direct reading of `src/ir/cmd_interpolate/`, scopes the work -to five milestones covering the ADR, the README section with executable -examples, correction of two inaccurate internal documents, six translations, -and the roadmap closure. Remaining work: all of it; the plan awaits approval -before any implementation begins. +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. + +Remaining work: all of it; the plan awaits approval before any implementation +begins. `D1-LEGACY` additionally awaits the owner's confirmation, though it is +written so that either answer leaves the documentation honest. From 7dd8307ddbd9a39199ce2a88de22ee73b8e96045 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 9 Sep 2026 15:25:06 +0200 Subject: [PATCH 03/24] Use the estate spelling of handwritten in the 4.4.1 plan The `spelling` gate rejects "hand-written". The plan already used "handwritten" in the other of its two occurrences, so this also makes the document internally consistent. Co-Authored-By: Claude Opus 5 (1M context) --- .../4-4-1-document-command-placeholder-contract-in-readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a9df0e3cd..6bbe66d58 100644 --- 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 @@ -627,7 +627,7 @@ boundary.** 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 hand-written controls in +- 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 From 6af5124dc85608eb4f426c0b21365c8be3852433 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 01:48:29 +0200 Subject: [PATCH 04/24] Record Stage A results in the 4.4.1 plan Re-verify facts A, B, and C against the code after rebasing the branch onto origin/main at 0ba6672f, and record the refreshed line citations in Progress so a resuming implementer does not open the wrong lines. The rebase moved the ADR ceiling from 020 to 026, so the plan's ADR becomes 027 and every reference is renumbered. PR 621 holds an unmerged and conflicted adr-021 filename, which would collide on merge. The plan header moves from DRAFT to IN PROGRESS now that implementation has begun. --- ...-command-placeholder-contract-in-readme.md | 121 +++++++++++++++--- 1 file changed, 101 insertions(+), 20 deletions(-) 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 index 6bbe66d58..c256ed8a4 100644 --- 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 @@ -6,9 +6,15 @@ This ExecPlan (execution plan) is a living document. The sections `Constraints`, `Conformance basis`, and `Verification plan` must be kept up to date as work proceeds. -Status: DRAFT +Status: IN PROGRESS -Revision 1. See `Revision note` at the foot of this document. +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 @@ -38,7 +44,7 @@ After this change: repository's documented-example harness, so the README cannot silently drift away from the implementation. - A new Architectural Decision Record, - `docs/adr-021-command-placeholder-contract.md`, records the three settled + `docs/adr-027-command-placeholder-contract.md`, records the three settled contract decisions and their rationale, and the design document points at it. - Three documents that currently misstate the contract are corrected — `docs/developers-guide.md`, `docs/users-guide.md`, and @@ -313,9 +319,9 @@ 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-021 §Placeholders) -> EP-M3 -> tests::readme_security::documented_safe_placeholder_manifest_builds -RM-4.4.1.c / FV-CPC-Q2 -> D2 -> EP-M1 (ADR-021 §Backticks) -> EP-M3 -> tests::readme_security::documented_backtick_manifest_is_rejected -RM-4.4.1.d / FV-CPC-Q3 -> D3 -> EP-M1 (ADR-021 §shlex guard) -> EP-M3 -> tests::readme_security::documented_backtick_manifest_is_rejected +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 RM-4.4.1.b / Fact A -> OBL-RECIPE-KIND -> tests::readme_security::placeholder_rewriting_differs_by_recipe_kind Fact A -> EP-M2 -> docs/developers-guide.md "Command interpolation contract" Fact A -> EP-M2 -> docs/users-guide.md "Review the safety boundary" (:1697, :1713) @@ -373,7 +379,7 @@ workaround. 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-021, and no completed execplan. A + `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. @@ -383,7 +389,7 @@ Stop and escalate when any threshold is reached. Do not work around them. - **Scope.** More than eighteen files changed, or more than 1200 net added lines across the whole plan. The expected set is sixteen paths: `README.md`; - the six translations; `docs/adr-021-command-placeholder-contract.md`; + the six translations; `docs/adr-027-command-placeholder-contract.md`; `docs/contents.md`; `docs/developers-guide.md`; `docs/formal-verification-methods-in-netsuke.md`; `docs/users-guide.md`; `docs/repository-layout.md`; `docs/roadmap.md`; @@ -705,7 +711,7 @@ new evidence. ### EP-M1 — settled contract, recorded -- Outcome: `docs/adr-021-command-placeholder-contract.md` exists and records +- 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. @@ -745,7 +751,7 @@ contradiction window entirely. placeholder contract*) and `:64-66` (§*Kani for command interpolation*), both of which say literal `$in` and `$out` "remain shell variables" unqualified. Also record in §*Command placeholder contract* that - `FV-CPC-Q1` through `FV-CPC-Q3` are now answered, pointing at ADR-021, and + `FV-CPC-Q1` through `FV-CPC-Q3` are now answered, pointing at ADR-027, and correct the `[^8]` footnote path from `src/ir/cmd_interpolate.rs` to `src/ir/cmd_interpolate/mod.rs`. @@ -796,7 +802,7 @@ contradiction window entirely. - 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-021 and the README prose agree word-for-word on the + 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 @@ -828,7 +834,7 @@ contradiction window entirely. - 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-021 and that translations + 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, @@ -851,7 +857,7 @@ 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-021 must say. +ADR-027 must say. - **`D1` (answers `FV-CPC-Q1`).** `{{ ins }}` and `{{ outs }}` are the supported placeholders, in both `command:` and `script:` recipes. In @@ -983,7 +989,7 @@ come first. 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-021. + 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 @@ -1041,7 +1047,7 @@ Run everything from the repository root, Expect `20`. If it is higher, use the next number and update every reference in this plan. -4. Write `docs/adr-021-command-placeholder-contract.md`, add its entry to +4. Write `docs/adr-027-command-placeholder-contract.md`, add its entry to `docs/contents.md` under `## Decision records` immediately above the ADR-020 entry, and add the design-document reference. Commit as EP-M1. @@ -1278,7 +1284,7 @@ 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-021-command-placeholder-contract.md`. Its required +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 @@ -1292,7 +1298,37 @@ reads naturally. ## Progress -- [ ] EP-M1 — ADR-021 written, indexed, and referenced from the design +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` :278. +- `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-M1 — ADR-027 written, indexed, and referenced from the design document. - [ ] EP-M2 — `docs/developers-guide.md`, `docs/users-guide.md`, `docs/formal-verification-methods-in-netsuke.md`, and any inaccurate doc @@ -1392,6 +1428,27 @@ reads naturally. 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. + ## Decision log - Decision `D1`: the supported placeholder set is `{{ ins }}` and `{{ outs }}` @@ -1472,7 +1529,7 @@ reads naturally. (`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-021 must not cite this module + 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. @@ -1535,6 +1592,19 @@ reads naturally. than a footnote — and that would be a code change outside this item. Date/Author: 2026-09-09, design review. Awaiting approval. +- 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 @@ -1549,7 +1619,7 @@ Before setting this plan to `COMPLETE`, reconcile each entry in `Surprises & discoveries` against the artefacts in `Conformance basis`: confirm that EP-M2 corrected the Fact A misstatements in both documents and the `[^8]` footnote, that `docs/formal-verification-methods-in-netsuke.md` records -`FV-CPC-Q1` through `FV-CPC-Q3` as answered with a pointer to ADR-021, and that +`FV-CPC-Q1` through `FV-CPC-Q3` as answered with a pointer to ADR-027, and that the 4.2.3 status discrepancy is either resolved elsewhere or recorded as knowingly deferred. @@ -1581,11 +1651,22 @@ Reference material gathered during planning, for the writer's use: `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-021 as evidence for why `D3` declines to make the + 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-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 From 19dec07753e55682b2b9434f8c1c57b981e35110 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 01:52:11 +0200 Subject: [PATCH 05/24] Add ADR-027 settling the command placeholder contract Netsuke's command interpolation splits recipe text into a part Netsuke rewrites and a part the shell owns. That split is a security boundary, and the prose across the developers' guide, users' guide, and the formal-verification document disagrees with the implementation on which dollar-prefixed forms are placeholders. ADR-027 settles three questions the formal-verification document records as open, and states the answers at the strength the code supports: - The supported placeholder set is `{{ ins }}`/`{{ outs }}` in both recipe kinds, plus `$in`/`$out` in `script:` recipes only, with the `script:`-only forms recorded as retained legacy behaviour. - Backtick handling is two mechanisms at two strengths: a promised marker-invariant, and a conservative whole-string parity check that is explicitly not promised and may widen without a breaking change. - The `shlex` guard is part of the observable acceptance contract (the rejection is stable and localized) but is not a stability commitment about the precise accepted set, because `Cargo.toml` carries a caret requirement rather than a pin. The record cites the implementing file and line for each decision, and distinguishes what is proved (the marker recognizer, by Kani) from what is property-tested (the backtick state machine and the guard). Index the ADR from docs/contents.md and cross-reference it from the netsuke-design.md section that describes command interpolation. Co-Authored-By: Claude Code --- docs/adr-027-command-placeholder-contract.md | 267 +++++++++++++++++++ docs/contents.md | 3 + docs/netsuke-design.md | 6 + 3 files changed, 276 insertions(+) create mode 100644 docs/adr-027-command-placeholder-contract.md diff --git a/docs/adr-027-command-placeholder-contract.md b/docs/adr-027-command-placeholder-contract.md new file mode 100644 index 000000000..da469bde4 --- /dev/null +++ b/docs/adr-027-command-placeholder-contract.md @@ -0,0 +1,267 @@ +# 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, and documented for users +in `README.md` under *Security and command interpolation*. + +## 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 supported placeholder set differs by recipe kind + +`{{ ins }}` and `{{ outs }}` are the supported placeholders in both `command:` +and `script:` recipes. In `script:` recipes Netsuke *additionally* rewrites bare +`$in` and `$out` at identifier boundaries — a boundary being a position not +adjacent to an alphanumeric character or an underscore. Every other +dollar-prefixed form, including `$ins`, `$outs`, `$input`, `$output`, and +`$PATH`, is literal text for the shell in both recipe kinds. + +The recognizer implementing the two halves of this rule is `find_substitution` +for `command:` recipes and `find_script_substitution` for `script:` recipes +(`src/ir/cmd_interpolate/mod.rs:261-297`). Only the latter consults +`try_match_dollar_placeholder`. + +The `script:`-only forms are **retained legacy behaviour, not a blessed +feature**. New manifests should use `{{ ins }}` and `{{ outs }}`, and the +README steers authors accordingly. Two consequences are stated wherever the +contract is documented, because neither is predictable from the rule: + +- A script that writes its own shell variable named `in` or `out`, such as + `in=foo; echo $in`, has that variable rewritten to input paths with no + diagnostic. +- Moving the same text from a `script:` to a `command:` silently changes its + meaning: `$in` there degrades to an ordinary, usually empty, shell variable. + +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 does not inspect backticks the author +wrote. A balanced pair is passed through to the shell and executed as command +substitution. The single exception is that `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 is outside both mechanisms, because +a backtick is its escape character rather than a command-substitution delimiter; +`is_valid_command_for_shell` returns `true` unconditionally for +`RecipeShell::PowerShell` (`src/ir/cmd_interpolate/mod.rs:227-228`). 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 today's rejects means previously rejected + manifests begin to build. Not treated as a breaking change. +- A `shlex` release that rejects text today's accepts 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 + +1. **Document the set the implementation actually has** — `{{ ins }}` and + `{{ outs }}` everywhere, plus `$in` and `$out` in scripts. Selected. +2. **Document a uniform set** — declare the two marker forms universal and + describe `$in` and `$out` as literal shell variables in both recipe kinds. + Rejected: it is false for `script:` recipes, which three documents currently + assert. Documenting a contract Netsuke does not honour is worse than + documenting an irregular one. +3. **Widen the implementation to match** — make `command:` recipes rewrite + `$in` and `$out` too, then document the uniform set. Rejected: it is a + production behaviour change on a security-sensitive path, and it would + silently change the meaning of existing commands that use those names as + shell variables. + +### 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.** Each of the three + decisions is backed by an executable example in the README section and by + parameters of `src/ir/cmd_interpolate/`'s existing test suite, so a later + change that breaks the documented behaviour fails a test rather than silently + invalidating prose. +- **Legacy behaviour is documented as legacy.** The `script:`-only `$in` and + `$out` forms are load-bearing for existing manifests, so removing them is not + this decision's to make; describing them as retained legacy with a warning + and a migration steer keeps the documentation honest under either future + answer. +- **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 use `script:` recipes and write their own `in` or `out` +shell variables will have those variables rewritten with no diagnostic. This is +now documented rather than silent, and the README recommends `{{ ins }}` and +`{{ outs }}`; a diagnostic for the shadowing case is follow-up work if the +legacy forms are retained indefinitely. + +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`, `find_script_substitution`, + `try_match_dollar_placeholder`). +- 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: [`README.md`](../README.md), section + *Security and command interpolation*; 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..0389a0105 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 + per recipe kind, the backtick-handling boundary, and the scope of the `shlex` + acceptance guard, with retained-legacy status for the `script:`-only forms. - [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/netsuke-design.md b/docs/netsuke-design.md index a0999f37d..d0eac66b6 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2830,6 +2830,12 @@ 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. +[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. The +`script:`-only `$in` and `$out` forms are retained legacy behaviour. + ### 6.4 Automatic Security as a "Friendliness" Feature The concept of being "friendlier" than `make` extends beyond syntactic sugar to From e9daf18211c24da0893bdd12453abfd2da4b1f8c Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 01:56:33 +0200 Subject: [PATCH 06/24] Record EP-M1 completion in the 4.4.1 plan Note the milestone commit, the two gate results, and the confirmation that the typos-config-builder refresh inside `make markdownlint` left the working tree clean. Record why the force-with-lease push was safe: `git patch-id --stable` reports the same three patch-ids before and after the rebase, so the rewrite changed lineage only, not content. Note the out-of-band PR title and Lody session rename. Co-Authored-By: Claude Code --- ...-command-placeholder-contract-in-readme.md | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) 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 index c256ed8a4..ee6127a9c 100644 --- 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 @@ -1298,6 +1298,30 @@ reads naturally. ## Progress +### 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 @@ -1328,8 +1352,9 @@ the record: - `tests/documentation_examples_tests.rs`: `EXPECTED_EXAMPLE_IDS` `readme-` block at :54-58, registry test at :143. -- [ ] EP-M1 — ADR-027 written, indexed, and referenced from the design - document. +- [x] EP-M1 — ADR-027 written, indexed, and referenced from the design + document. Landed as `3594b568`. `make check-fmt` and `make markdownlint` + pass. Awaiting the milestone CodeRabbit pass. - [ ] EP-M2 — `docs/developers-guide.md`, `docs/users-guide.md`, `docs/formal-verification-methods-in-netsuke.md`, and any inaccurate doc comment corrected. Precedes the README deliberately. From d1f652d660b87a7b1c58b849d42e2295d0ccc5c6 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 02:38:13 +0200 Subject: [PATCH 07/24] Clear the EP-M1 CodeRabbit findings The milestone review returned twelve findings over the whole branch delta. Six were accepted and fixed; six were rejected with reasons. No behaviour changed. Accepted: - Two stale `locales/en-GB/messages.ftl:179` citations in the plan became `:188`, the current line. - The ADR-ceiling step in `Concrete steps` now records the historical `20`, the observed `26` after the rebase, and that step 4 is already complete, so a re-run cannot mint a second record. - `docs/netsuke-design.md` no longer states a guard as applying to both recipe kinds when `is_valid_command_for_shell` applies it to `command:` recipes on the POSIX and Bash routes only. - ADR-027 no longer describes the README section as already published. At EP-M1 the README has no such section; `Status`, `Rationale`, `Consequences`, and `Implementation references` now present it as established by this change set. - The artefact checklist cited off-by-one step numbers, and the acceptance inventory named five test cases and three obligations where the plan defines seven and five. Both now match the plan. - The second-person constructions at two locations, which `docs/documentation-style-guide.md:39` bars outside `README.md`. Rejected: the ADR date change, because both the system clock and GitHub's HTTP `Date` header report 2026-09-19, which is what the ADR says. Also record the CodeRabbit triage as `D-CODERABBIT-EP-M1`, and note in `Surprises & discoveries` that the module doc comment links to an item that does not exist, latent because `lint-clippy` runs `cargo doc` without `--document-private-items`. Co-Authored-By: Claude Code --- docs/adr-027-command-placeholder-contract.md | 38 ++--- ...-command-placeholder-contract-in-readme.md | 132 ++++++++++++++---- docs/netsuke-design.md | 10 +- 3 files changed, 133 insertions(+), 47 deletions(-) diff --git a/docs/adr-027-command-placeholder-contract.md b/docs/adr-027-command-placeholder-contract.md index da469bde4..0509c90c2 100644 --- a/docs/adr-027-command-placeholder-contract.md +++ b/docs/adr-027-command-placeholder-contract.md @@ -3,8 +3,9 @@ ## Status Accepted. The command placeholder set, the backtick handling boundary, and the -scope of the `shlex` guard are fixed as stated below, and documented for users -in `README.md` under *Security and command interpolation*. +scope of the `shlex` guard are fixed as stated below. Roadmap item 4.4.1 and +the same change set carry them to users in a new `README.md` section, *Security +and command interpolation*, in every translated README. ## Date @@ -67,8 +68,9 @@ for `command:` recipes and `find_script_substitution` for `script:` recipes The `script:`-only forms are **retained legacy behaviour, not a blessed feature**. New manifests should use `{{ ins }}` and `{{ outs }}`, and the -README steers authors accordingly. Two consequences are stated wherever the -contract is documented, because neither is predictable from the rule: +README section this record establishes steers authors accordingly. Two +consequences are stated wherever the contract is documented, because neither is +predictable from the rule: - A script that writes its own shell variable named `in` or `out`, such as `in=foo; echo $in`, has that variable rewritten to input paths with no @@ -135,9 +137,9 @@ acceptance sets. The two drift directions are named separately because they are not symmetric: -- A `shlex` release that accepts text today's rejects means previously rejected +- 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 today's accepts means a working manifest +- 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. @@ -191,11 +193,12 @@ A second, `debug_assert!`-only use exists in `assert_shell_command` ## Rationale -- **The contract is stated where it can be falsified.** Each of the three - decisions is backed by an executable example in the README section and by - parameters of `src/ir/cmd_interpolate/`'s existing test suite, so a later - change that breaks the documented behaviour fails a test rather than silently - invalidating prose. +- **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. - **Legacy behaviour is documented as legacy.** The `script:`-only `$in` and `$out` forms are load-bearing for existing manifests, so removing them is not this decision's to make; describing them as retained legacy with a warning @@ -211,10 +214,10 @@ A second, `debug_assert!`-only use exists in `assert_shell_command` ## Consequences Manifest authors who use `script:` recipes and write their own `in` or `out` -shell variables will have those variables rewritten with no diagnostic. This is -now documented rather than silent, and the README recommends `{{ ins }}` and -`{{ outs }}`; a diagnostic for the shadowing case is follow-up work if the -legacy forms are retained indefinitely. +shell variables will have those variables rewritten with no diagnostic. This +becomes documented rather than silent, and the README will recommend +`{{ ins }}` and `{{ outs }}`; a diagnostic for the shadowing case is follow-up +work if the legacy forms are retained indefinitely. Authors can rely on the marker invariant and on the rejection diagnostic. Authors cannot rely on the accepted set across `shlex` versions, and cannot @@ -244,8 +247,9 @@ published interface. [`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: [`README.md`](../README.md), section - *Security and command interpolation*; detailed shell-route mechanics remain in +- 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*. 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 index ee6127a9c..0e9afda97 100644 --- 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 @@ -52,7 +52,7 @@ After this change: qualifying it to command recipes, that literal `$in` and `$out` are left alone. In a `script:` recipe they are not. -You can see it working by running `NETSUKE_REQUIRE_NINJA=1 make test` and +The change is observable by running `NETSUKE_REQUIRE_NINJA=1 make test` and observing the new cases `readme_security_tests::placeholder_rewriting_differs_by_recipe_kind`, `readme_security_tests::netsuke_owned_path_substitutions_are_quoted`, and @@ -150,7 +150,7 @@ property test `dollar_prefixed_shell_variables_are_preserved` `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:179`, +`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`, @@ -646,7 +646,7 @@ boundary.** - 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:179`. + `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 @@ -959,10 +959,10 @@ 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 treat it as you would 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. +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 @@ -1044,12 +1044,17 @@ Run everything from the repository root, ls docs/adr-*.md | sed 's/.*adr-0*\([0-9]*\).*/\1/' | sort -n | tail -1 ``` - Expect `20`. If it is higher, use the next number and update every reference - in this plan. + 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 above the ADR-020 - entry, and add the design-document reference. Commit as EP-M1. + `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`. ```sh make check-fmt 2>&1 | tee /tmp/check-fmt-netsuke-$(git branch --show-current).out @@ -1176,7 +1181,7 @@ Run everything from the repository root, 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 test 2>&1 | tee /tmp/test-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 ``` @@ -1199,17 +1204,26 @@ A reader can verify the outcome without reading any test: Quality criteria — what "done" means: -- Tests: `make test` passes. The five new cases in +- 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. -- Verification: `OBL-PLACEHOLDERS`, `OBL-BACKTICK-REJECT`, and - `OBL-SHLEX-SCOPE` are discharged with both negative-control transcripts - recorded. `OBL-STRUCTURAL-PARITY` is discharged by the step-10 output. + section existed: `documented_safe_placeholder_manifest_builds`, + `placeholder_rewriting_differs_by_recipe_kind`, + `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-RECIPE-KIND`, `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, which - skips when `ninja` is absent. +- 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. @@ -1474,6 +1488,22 @@ the record: 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 `D1`: the supported placeholder set is `{{ ins }}` and `{{ outs }}` @@ -1636,14 +1666,64 @@ the record: 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. + ## Outcomes & retrospective Not yet started. To be completed at EP-M5. Before setting this plan to `COMPLETE`, reconcile each entry in `Surprises & discoveries` against the artefacts in `Conformance basis`: confirm -that EP-M2 corrected the Fact A misstatements in both documents and the `[^8]` -footnote, that `docs/formal-verification-methods-in-netsuke.md` records +that EP-M2 corrected the Fact A misstatements in all three documents and the +`[^8]` footnote, that `docs/formal-verification-methods-in-netsuke.md` records `FV-CPC-Q1` through `FV-CPC-Q3` as answered with a pointer to ADR-027, and that the 4.2.3 status discrepancy is either resolved elsewhere or recorded as knowingly deferred. @@ -1652,12 +1732,12 @@ knowingly deferred. To be populated during implementation. At minimum, retain: -- the red transcript from `Concrete steps` step 5, showing both +- the red transcript from `Concrete steps` step 6, showing both missing-identifier failures and the registry-drift failure; -- the green transcript from step 6; -- both negative-control transcripts from step 7; -- the parity output from step 10; -- the gate summary from step 11, naming any gate that did not run. +- 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: diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index d0eac66b6..d2b1f8bf1 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2825,10 +2825,12 @@ 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 From 7300db7c24e2c9bcdbcd41444666de202bef741a Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 03:17:50 +0200 Subject: [PATCH 08/24] Correct the per-recipe-kind placeholder contract in three documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fact A — `{{ ins }}` and `{{ outs }}` work in both recipe kinds, bare `$in` and `$out` are rewritten in `script:` recipes only, and `$ins`, `$outs`, `$input`, `$output` are never rewritten — was already pinned by the tests in `src/ir/cmd_interpolate/`, but three documents stated it as if the placeholder set were the same in every recipe kind. Correct `docs/developers-guide.md`, `docs/users-guide.md`, and `docs/formal-verification-methods-in-netsuke.md` to state the set per recipe kind, and record in the formal-verification document that the three questions its contract section raised are now settled by ADR-027. Correct the `[^8]` footnote path to `src/ir/cmd_interpolate/mod.rs`. Correct the module doc comment on `src/ir/cmd_interpolate/mod.rs`, which made the same claim. No code changes. This milestone deliberately precedes the README section so the repository is never in a state where two normative documents disagree. Co-Authored-By: Claude Code --- docs/developers-guide.md | 25 +++-- ...-command-placeholder-contract-in-readme.md | 94 ++++++++++++++++--- .../formal-verification-methods-in-netsuke.md | 58 +++++++----- 3 files changed, 134 insertions(+), 43 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index e0b338728..9888bb7c9 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -392,13 +392,14 @@ 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 - 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. + that set. `{{ ins }}` and `{{ outs }}` are resolved per entry in both recipe + kinds; `$in` and `$out` are resolved only in scripts. Literal `$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. - `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 @@ -3887,8 +3888,14 @@ 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. +emitted by manifest rendering, plus the legacy `$in` and `$out` short forms in +`script:` recipes. Literal shell variables such as `$ins` and `$outs`, and +every other dollar-prefixed form, remain unchanged for the selected backend to +interpret in both recipe kinds. The `$in` and `$out` short forms are +`script:`-only, however: in a `command:` recipe they are ordinary shell +variables and are passed through untouched, so the same text means different +things depending on the enclosing recipe kind. See +[ADR-027](adr-027-command-placeholder-contract.md). `INS_TOKEN` and `OUTS_TOKEN` are machine-generated markers. They match exact text, so an adjacent identifier character does not suppress a marker 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 index 0e9afda97..fe882b14e 100644 --- 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 @@ -1064,20 +1064,38 @@ Run everything from the repository root, 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. + 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 - grep -rn -e 'only Netsuke markers' -e 'remain unchanged' \ - -e 'remain shell variables' -e 'remain literal shell variables' \ - README.md docs/*.md src/ir/cmd_interpolate/ \ - | grep -v '^docs/adr-' | grep -v '^docs/archive/' + 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/)' ``` - Every surviving hit must be qualified by recipe kind, or be - `docs/netsuke-design.md:290-291`, which is already correct and must not be - changed. Revision 1's grep (`'literal .\$in\|\$in. and .\$out. remain'`) - matched neither `docs/users-guide.md:1697`, whose claim wraps across a line - break, nor the developers-guide phrasing, and it did hit + 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. Every hit about `$in` or + `$out` must be qualified by recipe kind, except + `docs/netsuke-design.md:290-291`, which already states Fact A correctly and + must **not** be changed — it is the canonical wording the others converge on. + + 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 @@ -1366,12 +1384,64 @@ the record: - `tests/documentation_examples_tests.rs`: `EXPECTED_EXAMPLE_IDS` `readme-` block at :54-58, registry test at :143. +### EP-M2 — complete + +Four documents corrected, one doc comment fixed, no code touched. + +The four corrections are the ones `Concrete steps` step 5 names, plus the doc +comment. `docs/developers-guide.md` gained the per-recipe-kind placeholder set +and an ADR-027 link in §*Command interpolation contract* (~:3363) and a +corrected lowering-stages bullet (~:392). `docs/users-guide.md` split the +"Write shell dollar expressions normally" bullet into three, so `{{ ins }}`, +`$in`-in-`script:`, and the POSIX quoting context each get their own paragraph +(~:1950), and the migration bullet now says the short forms resolve only in +`script:` (~:1983). `docs/formal-verification-methods-in-netsuke.md` corrected +both the Kani section (~:64) and the contract section (~:265), the latter now +carrying a **Settled** paragraph that answers `FV-CPC-Q1` through `FV-CPC-Q3` +and points at ADR-027, plus the `[^8]` path fix to +`src/ir/cmd_interpolate/mod.rs` (~:346). The doc comment on +`src/ir/cmd_interpolate/mod.rs` now names `$in`/`$out` as script-only and says +which forms are literal in which recipe kind. + +The step-5 verification grep returns **no survivors at all**, which is stronger +than the plan predicted. The plan's `Acceptance evidence` anticipated surviving +hits for `$ins` and `$outs`, on the reasoning that those really are literal +shell variables in both recipe kinds. The reason they no longer survive is that +the corrected prose in all three documents now names `$in`/`$out` and the +recipe kind in the same sentence, and `$ins`/`$outs` alongside them, so the +multiline `rg -U` pattern `\$in.{0,80}remain\s+shell\s+variables` no longer +matches — the words are still true, they are just no longer adjacent in that +order. This is not a weakened check; the pattern is unchanged from the plan. + +Gate evidence, run sequentially through `scrutineer` with each gate teed to +`/tmp/--epm2.out`: `make check-fmt` PASS +(`143 files left unchanged`), `make markdownlint` PASS (`0 error(s)`, 143 +files, spelling included), `make typecheck` PASS, `make lint` PASS with +`PATH="$HOME/go/bin:$PATH"` (*cargo doc*, *cargo clippy -D warnings*, both +Whitaker passes, Python lint, `yamllint`, `actionlint`), `make test` PASS +(`3188 tests run: 3188 passed, 5 skipped`, plus 82/2/39 doctests), `make nixie` +PASS. `scrutineer` additionally hashed the five modified files before and after +the gate run and found them byte-identical, so no gate reformatted the tree. + +One formatting repair was needed after the first `mdtablefix` pass. The rewrap +split a bold span across a line break in `docs/developers-guide.md`, rendering +``**`script:`-only**`` as a stray `**` at end of line followed by +`` `script:`-only** `` on the next. Reworded to drop the bold entirely rather +than fight the wrapper. Recorded because it is the second time in this plan that +`mdtablefix --wrap` has damaged emphasis, and it is the reason the file is +worth reading back after each automated rewrap rather than trusting exit 0. + +`docs/netsuke-design.md:290-291` was deliberately **not** edited, per the plan: +it is the canonical wording the other three converge on. + - [x] EP-M1 — ADR-027 written, indexed, and referenced from the design document. Landed as `3594b568`. `make check-fmt` and `make markdownlint` pass. Awaiting the milestone CodeRabbit pass. -- [ ] EP-M2 — `docs/developers-guide.md`, `docs/users-guide.md`, +- [x] EP-M2 — `docs/developers-guide.md`, `docs/users-guide.md`, `docs/formal-verification-methods-in-netsuke.md`, and any inaccurate doc - comment corrected. Precedes the README deliberately. + comment corrected. Precedes the README deliberately. All six gates pass; + the step-5 grep returns *no* survivors at all. + - [ ] EP-M3 — README section and three executable examples; red observed before green; three negative controls recorded after the commit. - [ ] EP-M4 — six translated READMEs regain structural parity; diff --git a/docs/formal-verification-methods-in-netsuke.md b/docs/formal-verification-methods-in-netsuke.md index bf83dc21e..f2a229165 100644 --- a/docs/formal-verification-methods-in-netsuke.md +++ b/docs/formal-verification-methods-in-netsuke.md @@ -62,12 +62,14 @@ stronger proof obligations become worthwhile.[^7] ### Kani for command interpolation `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] +compact, load-bearing, and security-sensitive. It recognizes the internal +`INS_TOKEN` and `OUTS_TOKEN` markers emitted by manifest rendering in both +recipe kinds, and additionally the short forms `$in` and `$out` in `script:` +recipes; in a `command:` those two are literal shell variables, as `$ins` and +`$outs` always are. 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] 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,32 @@ 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 the internal `INS_TOKEN` and `OUTS_TOKEN` +markers emitted by manifest rendering in both recipe kinds, and additionally +the short forms `$in` and `$out` in `script:` recipes. In a `command:` recipe +those two are literal shell variables, and `$ins` and `$outs` are literal in +both. 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. + +**Settled.** This contract is documented for users in the README under +*Security and command interpolation*, and decided 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 }}` in both recipe + kinds, plus `$in` and `$out` in `script:` recipes only; the short forms are + retained legacy behaviour, not a blessed feature. +- 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 makes no guarantee about backticks the + author wrote, which the shell will execute. +- `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 +343,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 From 6020f306970bf9a185c4b9d3f606380a11694079 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 03:36:28 +0200 Subject: [PATCH 09/24] Triage the EP-M2 CodeRabbit pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings were real and are fixed here; three were rejected. Real, and introduced by `efb5ea17`: the **Settled** paragraph in `docs/formal-verification-methods-in-netsuke.md` stated in the present tense that the contract is documented in the README, but the README section does not exist until EP-M3. The ADR `Status` line carried the same ambiguity and is reworded too. This commit exists to remove a document-claims-something-untrue window, so that it opened one is worth recording. Real, and pre-existing: three paragraphs read as though the unmatched-backtick parity check and the `shlex` guard applied to both recipe kinds. They apply to `command:` only — `is_valid_command_for_shell` is reached from `interpolate_command_with_bindings` alone, and `interpolate_script_with_bindings` routes to `substitute_script`, which never calls it. The marker-in-backticks invariant *is* shared, so the fix separates that invariant from the two follow-on checks. Rejected: the plan's literal README section label keeps its second person, which `docs/documentation-style-guide.md:39` permits in `README.md`; and the ADR date is 2026-09-19, re-verified. Co-Authored-By: Claude Code --- docs/adr-027-command-placeholder-contract.md | 7 ++- docs/developers-guide.md | 8 ++- ...-command-placeholder-contract-in-readme.md | 57 +++++++++++++++++++ .../formal-verification-methods-in-netsuke.md | 23 ++++---- 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/docs/adr-027-command-placeholder-contract.md b/docs/adr-027-command-placeholder-contract.md index 0509c90c2..fdbe42462 100644 --- a/docs/adr-027-command-placeholder-contract.md +++ b/docs/adr-027-command-placeholder-contract.md @@ -3,9 +3,10 @@ ## 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 and -the same change set carry them to users in a new `README.md` section, *Security -and command interpolation*, in every translated README. +scope of the `shlex` guard are fixed as stated below. Roadmap item 4.4.1 will +carry them to users in a new `README.md` section, *Security and command +interpolation*, in every translated README; that section is planned work, not +yet published, and this record is authoritative until it lands. ## Date diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 9888bb7c9..1c3c57d5e 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -3901,9 +3901,11 @@ things depending on the enclosing recipe kind. See 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. For +`command:` recipes the POSIX 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 index fe882b14e..3fbe0ad79 100644 --- 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 @@ -1786,6 +1786,63 @@ it is the canonical wording the other three converge on. 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. + + Date/Author: 2026-09-19, implementing agent. + ## Outcomes & retrospective Not yet started. To be completed at EP-M5. diff --git a/docs/formal-verification-methods-in-netsuke.md b/docs/formal-verification-methods-in-netsuke.md index f2a229165..fd7b6171f 100644 --- a/docs/formal-verification-methods-in-netsuke.md +++ b/docs/formal-verification-methods-in-netsuke.md @@ -66,10 +66,11 @@ compact, load-bearing, and security-sensitive. It recognizes the internal `INS_TOKEN` and `OUTS_TOKEN` markers emitted by manifest rendering in both recipe kinds, and additionally the short forms `$in` and `$out` in `script:` recipes; in a `command:` those two are literal shell variables, as `$ins` and -`$outs` always are. 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] +`$outs` always are. 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 @@ -268,12 +269,14 @@ The interpolation layer recognizes the internal `INS_TOKEN` and `OUTS_TOKEN` markers emitted by manifest rendering in both recipe kinds, and additionally the short forms `$in` and `$out` in `script:` recipes. In a `command:` recipe those two are literal shell variables, and `$ins` and `$outs` are literal in -both. 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. - -**Settled.** This contract is documented for users in the README under -*Security and command interpolation*, and decided in +both. 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 will document this contract for users in the +README under *Security and command interpolation*, and it is decided in [ADR-027](adr-027-command-placeholder-contract.md). The three questions this section raised are now answered: From dab0ee524b0123d94967e306e5d8beceb71b3054 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 03:53:53 +0200 Subject: [PATCH 10/24] Scope the PowerShell exemption to the half of the invariant it covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-review of `24197456` found that ADR-027 said "PowerShell is outside both mechanisms", conflating the two halves of the promised invariant. PowerShell is outside the backtick half — its backtick is an escape, not a command-substitution delimiter — but it is inside the `$( … )` half: `power_shell_marker_protection` treats an active command substitution as protected and rejects the marker, the same outcome as POSIX by a different rule, pinned by `power_shell_rejects_markers_without_a_context_safe_encoder`. This is a second axis from the EP-M2 finding about recipe kinds: that one was `command:` versus `script:`, this one is POSIX versus PowerShell. The EP-M1 pass reviewed the ADR and missed it, because the pass was read against the recipe-kind axis the plan had been discussing. The same conflation appears twice in the plan's own `D2` narrative, so both were corrected with the ADR. The `D2` route-scope bullet now instructs EP-M3 that the README prose must not say PowerShell is exempt from the marker invariant outright. Co-Authored-By: Claude Code --- docs/adr-027-command-placeholder-contract.md | 20 ++++-- ...-command-placeholder-contract-in-readme.md | 64 ++++++++++++++++--- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/docs/adr-027-command-placeholder-contract.md b/docs/adr-027-command-placeholder-contract.md index fdbe42462..8243ae95c 100644 --- a/docs/adr-027-command-placeholder-contract.md +++ b/docs/adr-027-command-placeholder-contract.md @@ -111,13 +111,21 @@ substitution. The single exception is that `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 is outside both mechanisms, because -a backtick is its escape character rather than a command-substitution delimiter; +**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`). 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. +`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 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 index 3fbe0ad79..08e3f4567 100644 --- 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 @@ -134,12 +134,20 @@ property test `dollar_prefixed_shell_variables_are_preserved` 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 exempt from both. `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 +- 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`). + (`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. A balanced backtick pair containing text the author typed reaches the shell and is executed as command substitution. The only exception is that `quote_double_quoted_path` @@ -894,10 +902,16 @@ ADR-027 must say. - **Not a guarantee at all.** Netsuke does not inspect backticks the author wrote. A balanced pair is passed to the shell and executed as command substitution. - - **Route scope.** PowerShell is outside both, because its backtick is an - escape character. The parity check is also `command:`-only; a `script:` - recipe gets the marker invariant but no parity check. State this, because - a reader who moves a recipe to `script:` otherwise loses a check silently. + - **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 @@ -1841,6 +1855,38 @@ it is the canonical wording the other three converge on. 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 From f3c717f2ab71e795ac58808cd5c3460470a2f3ca Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 15:07:21 +0200 Subject: [PATCH 11/24] Reconcile the 4.4.1 branch with ADR-034 ADR-034 landed on main on 2026-09-20 and reversed this branch's central finding. Script recipes no longer lower bare `$in` and `$out` to paths, so the placeholder set is now uniform: `{{ ins }}` and `{{ outs }}` are the only markers, and every dollar-prefixed form is a shell variable in both recipe kinds. The branch had just finished documenting the opposite. Reconcile it: - ADR-027 now states the uniform set and cites ADR-034 as owning that decision, rather than shipping born-superseded. Its superseded reasoning is retained under Options considered, with a fourth option recording what ADR-034 actually did: narrow the implementation rather than widen it or document the irregularity. - The developers' guide and the formal-verification document drop the asymmetry. The command-only scope of the backtick and `shlex` guards, which this branch added and which ADR-034 did not touch, is kept. - The design document and the contents index follow. The ExecPlan is a living document, so the reversal is recorded rather than erased. Fact A is inverted and annotated with its own reversal, D1 becomes a uniform rule, D1-LEGACY closes as D1-RESOLVED, and OBL-RECIPE-KIND becomes OBL-UNIFORM-SET, whose seeded fault is the pre-ADR-034 behaviour so the control also shows the suite would have caught the change. Superseded discovery entries are marked in place. D2, D3, OBL-QUOTING, OBL-BACKTICK-REJECT and OBL-SHLEX-SCOPE are unaffected, re-verified against the rebased tree: the backtick check is still whole-string parity, PowerShell is still exempt, and scripts still bypass both guards. OBL-SHLEX-SCOPE is now the only recipe-kind asymmetry the README must describe. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr-027-command-placeholder-contract.md | 104 +++--- docs/contents.md | 6 +- docs/developers-guide.md | 32 +- ...-command-placeholder-contract-in-readme.md | 323 +++++++++++------- .../formal-verification-methods-in-netsuke.md | 39 +-- docs/netsuke-design.md | 6 +- 6 files changed, 310 insertions(+), 200 deletions(-) diff --git a/docs/adr-027-command-placeholder-contract.md b/docs/adr-027-command-placeholder-contract.md index 8243ae95c..d8d413aaf 100644 --- a/docs/adr-027-command-placeholder-contract.md +++ b/docs/adr-027-command-placeholder-contract.md @@ -8,9 +8,18 @@ carry them to users in a new `README.md` section, *Security and command interpolation*, in every translated README; that section is planned work, not yet published, and this record is authoritative until it lands. +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 +2026-09-19, revised 2026-09-23 ## Context and problem statement @@ -53,31 +62,27 @@ behaviour are promises. This record settles both. ## Decision -### The supported placeholder set differs by recipe kind +### The placeholder set is uniform across recipe kinds -`{{ ins }}` and `{{ outs }}` are the supported placeholders in both `command:` -and `script:` recipes. In `script:` recipes Netsuke *additionally* rewrites bare -`$in` and `$out` at identifier boundaries — a boundary being a position not -adjacent to an alphanumeric character or an underscore. Every other -dollar-prefixed form, including `$ins`, `$outs`, `$input`, `$output`, and -`$PATH`, is literal text for the shell in both recipe kinds. +`{{ 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 to interpret, in both +recipe kinds. The Ninja backend doubles the dollar so the shell receives the +text unchanged. -The recognizer implementing the two halves of this rule is `find_substitution` -for `command:` recipes and `find_script_substitution` for `script:` recipes -(`src/ir/cmd_interpolate/mod.rs:261-297`). Only the latter consults -`try_match_dollar_placeholder`. +Three terms are kept distinct throughout, following +[ADR-034](adr-034-preserve-script-in-out-as-shell-variables.md): -The `script:`-only forms are **retained legacy behaviour, not a blessed -feature**. New manifests should use `{{ ins }}` and `{{ outs }}`, and the -README section this record establishes steers authors accordingly. Two -consequences are stated wherever the contract is documented, because neither is -predictable from the rule: +- a **marker** is the Netsuke-owned `{{ ins }}` or `{{ outs }}` placeholder; +- an **internal token** is the `INS_TOKEN` or `OUTS_TOKEN` sentinel that exists + only between manifest rendering and interpolation; +- a **shell variable** is handwritten text such as `$PATH` or `$in` that + Netsuke does not rewrite. -- A script that writes its own shell variable named `in` or `out`, such as - `in=foo; echo $in`, has that variable rewritten to input paths with no - diagnostic. -- Moving the same text from a `script:` to a `command:` silently changes its - meaning: `$in` there degrades to an ordinary, usually empty, shell variable. +The recognizer is `find_substitution`, which matches the two internal tokens +and nothing else. `find_script_substitution` delegates to it, so both recipe +kinds resolve the same set. 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 @@ -162,18 +167,27 @@ A second, `debug_assert!`-only use exists in `assert_shell_command` ### The placeholder set -1. **Document the set the implementation actually has** — `{{ ins }}` and - `{{ outs }}` everywhere, plus `$in` and `$out` in scripts. Selected. -2. **Document a uniform set** — declare the two marker forms universal and - describe `$in` and `$out` as literal shell variables in both recipe kinds. - Rejected: it is false for `script:` recipes, which three documents currently - assert. Documenting a contract Netsuke does not honour is worse than - documenting an irregular one. +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, then document the uniform set. Rejected: it is a - production behaviour change on a security-sensitive path, and it would - silently change the meaning of existing commands that use those names as - shell variables. + `$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 @@ -208,11 +222,10 @@ A second, `debug_assert!`-only use exists in `assert_shell_command` example for each of the three decisions. A later change that breaks the documented behaviour therefore fails a test rather than silently invalidating prose. -- **Legacy behaviour is documented as legacy.** The `script:`-only `$in` and - `$out` forms are load-bearing for existing manifests, so removing them is not - this decision's to make; describing them as retained legacy with a warning - and a migration steer keeps the documentation honest under either future - answer. +- **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. @@ -222,11 +235,13 @@ A second, `debug_assert!`-only use exists in `assert_shell_command` ## Consequences -Manifest authors who use `script:` recipes and write their own `in` or `out` -shell variables will have those variables rewritten with no diagnostic. This -becomes documented rather than silent, and the README will recommend -`{{ ins }}` and `{{ outs }}`; a diagnostic for the shadowing case is follow-up -work if the legacy forms are retained indefinitely. +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 @@ -245,8 +260,7 @@ published interface. - Placeholder recognition: [`src/ir/cmd_interpolate/mod.rs`](../src/ir/cmd_interpolate/mod.rs) - (`find_substitution`, `find_script_substitution`, - `try_match_dollar_placeholder`). + (`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/). diff --git a/docs/contents.md b/docs/contents.md index 0389a0105..9ea7d0521 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -170,9 +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 - per recipe kind, the backtick-handling boundary, and the scope of the `shlex` - acceptance guard, with retained-legacy status for the `script:`-only forms. +- [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 1c3c57d5e..85cc47ba3 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -392,14 +392,14 @@ 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. `{{ ins }}` and `{{ outs }}` are resolved per entry in both recipe - kinds; `$in` and `$out` are resolved only in scripts. Literal `$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. + that set. Only `{{ ins }}` and `{{ outs }}` markers are resolved per entry, + identically in both recipe kinds. 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. - `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 @@ -3887,15 +3887,13 @@ 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, plus the legacy `$in` and `$out` short forms in -`script:` recipes. Literal shell variables such as `$ins` and `$outs`, and -every other dollar-prefixed form, remain unchanged for the selected backend to -interpret in both recipe kinds. The `$in` and `$out` short forms are -`script:`-only, however: in a `command:` recipe they are ordinary shell -variables and are passed through untouched, so the same text means different -things depending on the enclosing recipe kind. See -[ADR-027](adr-027-command-placeholder-contract.md). +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 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 index 08e3f4567..596f8122e 100644 --- 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 @@ -105,19 +105,22 @@ conflating them is a goal of this plan. These were established by reading the implementation, not by trusting existing prose. Cite them when writing. -**Fact A — the supported placeholder set differs by recipe kind.** -`find_substitution` (`src/ir/cmd_interpolate/mod.rs:261-266`) matches only -`INS_TOKEN` and `OUTS_TOKEN`, and is what `command:` recipes use. -`find_script_substitution` (`src/ir/cmd_interpolate/mod.rs:269-275`) also -matches `$in` and `$out` via `try_match_dollar_placeholder` -(`src/ir/cmd_interpolate/mod.rs:277-297`), which requires a non-alphanumeric, -non-underscore character on each side. So `$in` and `$out` **are** rewritten in -a `script:`, and **are not** rewritten in a `command:`. `$ins`, `$outs`, -`$input`, `$output`, and `$PATH` are never rewritten in either. Confirmed by -`src/ir/cmd_interpolate_tests.rs:42-50` -(`interpolate_command_preserves_dollar_prefixed_shell_variables`) and the -property test `dollar_prefixed_shell_variables_are_preserved` -(`src/ir/cmd_interpolate_property_tests.rs:55-66`). +**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.** @@ -186,13 +189,12 @@ same Netsuke source can therefore get different acceptance sets. This matters to (`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:3186-3201`, section **Command interpolation - contract**. States "Literal shell variables such as `$in`, `$out`, `$ins`, and - `$outs` remain unchanged". True for a `command:`; false for a `script:` - (Fact A). -- `docs/users-guide.md:1649-1718`, section **Review the safety boundary**. - User-facing and largely accurate; the README will link to it rather than - restate it. +- `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 @@ -315,6 +317,10 @@ at commit `81d44f89`: - `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 @@ -330,9 +336,9 @@ 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 -RM-4.4.1.b / Fact A -> OBL-RECIPE-KIND -> tests::readme_security::placeholder_rewriting_differs_by_recipe_kind +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/users-guide.md "Review the safety boundary" (:1697, :1713) 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 @@ -427,8 +433,8 @@ Stop and escalate when any threshold is reached. Do not work around them. 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` carries a known live instance of this: see the - `D1-LEGACY` note in `Decision log`. + README promises. `D1` carried a live instance of this until ADR-034 settled + it upstream; see `D1-RESOLVED` in `Decision log`. ## Risks @@ -575,38 +581,42 @@ Assumptions relied upon, not verified here: 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 **`command:`** - example and require it to survive into the generated Ninja verbatim, as - `$$in`. That fails only if the recipe-kind asymmetry breaks, which is the + 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-RECIPE-KIND` — the placeholder set really does differ by recipe kind.** +**`OBL-UNIFORM-SET` — the placeholder set is the same in both recipe kinds.** -- Statement: `$in` and `$out` are rewritten to paths in a `script:` recipe and - left as literal shell text in a `command:` recipe; `$ins`, `$outs`, `$input`, - and `$output` are left alone in both. -- Method: parameterized `rstest` over the (placeholder, recipe kind) matrix, +- 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 is the plan's headline discovery, the reason three documents - are wrong today, and the most surprising claim the README will make. Shipping - it with no executable counterpart would reproduce exactly the failure mode - this section exists to prevent: a documentation claim nobody can falsify. - Absent from revision 1; added at design review. -- Domain: the ten cells formed by `{{ ins }}`, `{{ outs }}`, `$in`, `$out`, - and `$ins`, each crossed with `command:` and `script:`. +- 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: the twelve cells formed by `{{ ins }}`, `{{ outs }}`, `$in`, `$out`, + `$ins`, and `$input`, each crossed with `command:` and `script:`. - Artefact: `tests/readme_security_tests.rs`, case - `placeholder_rewriting_differs_by_recipe_kind`. + `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: both arms of each row are asserted, so the test fails against an - implementation that rewrote `$in` in both recipe kinds **and** against one - that rewrote it in neither. A single-arm test would pass against the - currently documented — and wrong — uniform behaviour, which is precisely how - the existing misstatement survived. As a seeded fault, remove the two - `try_match_dollar_placeholder` calls from `find_script_substitution` - (`src/ir/cmd_interpolate/mod.rs:269-275`); the `script:` rows must fail. - Revert immediately. +- 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.** @@ -671,10 +681,13 @@ boundary.** `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 and is currently - documented nowhere. A single example per branch is decisive because the two - code paths are distinct functions with no shared guard - (`src/ir/cmd_interpolate/mod.rs:189-212`). +- 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 @@ -771,15 +784,18 @@ contradiction window entirely. - 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`. + `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 three corrected statements agree with each other and with - `docs/netsuke-design.md:290-291`; no historical document (`docs/archive/`, any - `docs/adr-0*.md`, any completed execplan) was edited. + 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 @@ -797,7 +813,7 @@ contradiction window entirely. 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-RECIPE-KIND`, `OBL-QUOTING`, `OBL-BACKTICK-REJECT`, and + `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 @@ -867,22 +883,22 @@ 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 - supported placeholders, in both `command:` and `script:` recipes. In - `script:` recipes Netsuke *additionally* rewrites bare `$in` and `$out`, at - identifier boundaries. Everything else — `$ins`, `$outs`, `$input`, `$output`, - `$PATH` — is literal text for the shell, in both recipe kinds. Netsuke does - not expose Ninja's own `$in` / `$out` rule variables, because it bakes - resolved paths into each content-hashed rule. - - The `script:` forms are documented as **retained legacy behaviour, not a - blessed feature**, and the README steers authors to `{{ ins }}` and - `{{ outs }}` in new manifests. Two consequences must be stated, because both - are foot-guns a reader would not predict: a script that legitimately writes - `in=foo; echo $in` has its own shell variable rewritten to input paths with - no diagnostic; and moving that text from `script:` to `command:` silently - changes its meaning, because `$in` there degrades to an empty shell variable. - See `D1-LEGACY` in `Decision log` for the open question this raises. +- **`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 @@ -986,11 +1002,13 @@ come first. 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 }}`, `$in`, `$out`, and the inert `$ins`/`$outs`/ + `{{ 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. Mark - the `$in` / `$out` row as retained legacy, with the shadowing caveat from - `D1`. The accepting example fence follows. + 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. @@ -1048,10 +1066,14 @@ Run everything from the repository root, 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 call `try_match_dollar_placeholder`, and the - Fluent line + `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 @@ -1097,10 +1119,11 @@ Run everything from the repository root, 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. Every hit about `$in` or - `$out` must be qualified by recipe kind, except - `docs/netsuke-design.md:290-291`, which already states Fact A correctly and - must **not** be changed — it is the canonical wording the others converge on. + 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 @@ -1156,8 +1179,9 @@ Run everything from the repository root, cargo nextest run --test readme_security_tests cp /tmp/readme-control.md README.md - # Control 2 (OBL-RECIPE-KIND): remove the two try_match_dollar_placeholder - # calls from find_script_substitution; the script rows must fail. + # 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 @@ -1246,7 +1270,7 @@ Quality criteria — what "done" means: `odd_backtick_count_without_markers_is_rejected`, and `shlex_gate_applies_to_commands_not_scripts`. - Verification: all five obligations are discharged — - `OBL-PLACEHOLDERS`, `OBL-RECIPE-KIND`, `OBL-QUOTING`, `OBL-BACKTICK-REJECT`, + `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. @@ -1378,8 +1402,9 @@ the record: `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` :278. + `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. @@ -1464,8 +1489,39 @@ it is the canonical wording the other three converge on. ## Surprises & discoveries -- Observation: `$in` and `$out` **are** substituted in `script:` recipes, - contradicting a plain reading of both `docs/developers-guide.md:3186-3190` and +- 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 @@ -1519,10 +1575,12 @@ it is the canonical wording the other three converge on. line" would have found test code at the wrong `assert_shell_command` citation and could have triggered the contract-disagreement tolerance spuriously. -- Observation: `docs/users-guide.md:1697-1698` and `:1713-1715` also - contradict 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 +- 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 @@ -1710,26 +1768,29 @@ it is the canonical wording the other three converge on. artefact in the plan would already have been lost. Date/Author: 2026-09-09, design review. -- Decision `D1-LEGACY`: the `script:`-only `$in` / `$out` forms are documented - as retained legacy behaviour, with an explicit shadowing warning and a steer - towards `{{ ins }}` / `{{ outs }}`, rather than as a blessed feature. - Rationale: the evidence points both ways and the plan must not silently pick - one. *Intended:* three `#[cfg(unix)]` end-to-end cases exercise the quoted - contexts (`tests/ninja_dollar_escaping_tests.rs:361-393`). *Vestigial:* the - users' guide already tells authors to migrate away - (`docs/users-guide.md:1713-1715`); the Ninja-escaping case covering the - command-recipe pass-through is named `legacy_marker_aliases` - (`tests/ninja_dollar_escaping_tests.rs:207`); and the Kani harness doc - comment asserts "Literal `$in` and `$out` must remain shell text" - (`src/ir/cmd_interpolate/verification.rs:4`). Documenting current behaviour - plus a direction of travel is honest under either reading and commits the - project to neither. - - **This decision needs the owner's confirmation.** If the intent is that these - forms be withdrawn before 1.0, the README should say so. If they are - supported indefinitely, the shadowing hazard deserves a diagnostic rather - than a footnote — and that would be a code change outside this item. - Date/Author: 2026-09-09, design review. Awaiting approval. +- 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 @@ -1934,6 +1995,40 @@ Reference material gathered during planning, for the writer's use: ## 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 diff --git a/docs/formal-verification-methods-in-netsuke.md b/docs/formal-verification-methods-in-netsuke.md index fd7b6171f..705e090f5 100644 --- a/docs/formal-verification-methods-in-netsuke.md +++ b/docs/formal-verification-methods-in-netsuke.md @@ -62,13 +62,12 @@ stronger proof obligations become worthwhile.[^7] ### Kani for command interpolation `src/ir/cmd_interpolate/mod.rs` is another high-value target because it is -compact, load-bearing, and security-sensitive. It recognizes the internal -`INS_TOKEN` and `OUTS_TOKEN` markers emitted by manifest rendering in both -recipe kinds, and additionally the short forms `$in` and `$out` in `script:` -recipes; in a `command:` those two are literal shell variables, as `$ins` and -`$outs` always are. 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 +compact, load-bearing, and security-sensitive. It 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. 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] @@ -265,24 +264,26 @@ Three contracts should be documented before proofs become gating checks. ### Command placeholder contract -The interpolation layer recognizes the internal `INS_TOKEN` and `OUTS_TOKEN` -markers emitted by manifest rendering in both recipe kinds, and additionally -the short forms `$in` and `$out` in `script:` recipes. In a `command:` recipe -those two are literal shell variables, and `$ins` and `$outs` are literal in -both. 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. +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 will document this contract for users in the README under *Security and command interpolation*, and it is decided 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 }}` in both recipe - kinds, plus `$in` and `$out` in `script:` recipes only; the short forms are - retained legacy behaviour, not a blessed feature. +- 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 diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index d2b1f8bf1..d69df922a 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2835,8 +2835,10 @@ 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. The -`script:`-only `$in` and `$out` forms are retained legacy behaviour. +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 From 24dfc3fe48ff7b018526cad8ee78dc7955d4b738 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 18:11:56 +0200 Subject: [PATCH 12/24] Reconcile the EP-M2 progress record with the rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EP-M2 completion record still described the document corrections as they were written on 2026-09-22, but the ADR-034 reconciliation undid most of them the next day. A Progress section must reflect the actual state of the work, so it now reads as two layers: what the milestone originally wrote, and what stands after the rebase. What stands is narrower than the record implied. The users' guide and the interpolation module carry no branch change at all and are byte-identical to origin/main, because main's own ADR-034 rewrite is better than the branch's — it added the marker / internal-token / shell-variable glossary this plan had asked for. The developers' guide keeps only its still-correct clarification that the backtick and shlex guards are command-only. The design document, previously left alone as canonical, is now minimally edited to cite ADR-034. EP-M2 stays complete: its obligation was that no document contradict another about the placeholder contract, and the tree is consistent. The record now says that this is satisfied largely by main's work rather than the branch's. Also marks the original EP-M2 gate evidence as stale for acceptance, since it was bound to a head that no longer exists, and cites the re-gated figures for the rebased head. Co-Authored-By: Claude Opus 5 (1M context) --- ...-command-placeholder-contract-in-readme.md | 109 ++++++++++-------- 1 file changed, 60 insertions(+), 49 deletions(-) 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 index 596f8122e..a36b7b0b3 100644 --- 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 @@ -1423,55 +1423,66 @@ the record: - `tests/documentation_examples_tests.rs`: `EXPECTED_EXAMPLE_IDS` `readme-` block at :54-58, registry test at :143. -### EP-M2 — complete - -Four documents corrected, one doc comment fixed, no code touched. - -The four corrections are the ones `Concrete steps` step 5 names, plus the doc -comment. `docs/developers-guide.md` gained the per-recipe-kind placeholder set -and an ADR-027 link in §*Command interpolation contract* (~:3363) and a -corrected lowering-stages bullet (~:392). `docs/users-guide.md` split the -"Write shell dollar expressions normally" bullet into three, so `{{ ins }}`, -`$in`-in-`script:`, and the POSIX quoting context each get their own paragraph -(~:1950), and the migration bullet now says the short forms resolve only in -`script:` (~:1983). `docs/formal-verification-methods-in-netsuke.md` corrected -both the Kani section (~:64) and the contract section (~:265), the latter now -carrying a **Settled** paragraph that answers `FV-CPC-Q1` through `FV-CPC-Q3` -and points at ADR-027, plus the `[^8]` path fix to -`src/ir/cmd_interpolate/mod.rs` (~:346). The doc comment on -`src/ir/cmd_interpolate/mod.rs` now names `$in`/`$out` as script-only and says -which forms are literal in which recipe kind. - -The step-5 verification grep returns **no survivors at all**, which is stronger -than the plan predicted. The plan's `Acceptance evidence` anticipated surviving -hits for `$ins` and `$outs`, on the reasoning that those really are literal -shell variables in both recipe kinds. The reason they no longer survive is that -the corrected prose in all three documents now names `$in`/`$out` and the -recipe kind in the same sentence, and `$ins`/`$outs` alongside them, so the -multiline `rg -U` pattern `\$in.{0,80}remain\s+shell\s+variables` no longer -matches — the words are still true, they are just no longer adjacent in that -order. This is not a weakened check; the pattern is unchanged from the plan. - -Gate evidence, run sequentially through `scrutineer` with each gate teed to -`/tmp/--epm2.out`: `make check-fmt` PASS -(`143 files left unchanged`), `make markdownlint` PASS (`0 error(s)`, 143 -files, spelling included), `make typecheck` PASS, `make lint` PASS with -`PATH="$HOME/go/bin:$PATH"` (*cargo doc*, *cargo clippy -D warnings*, both -Whitaker passes, Python lint, `yamllint`, `actionlint`), `make test` PASS -(`3188 tests run: 3188 passed, 5 skipped`, plus 82/2/39 doctests), `make nixie` -PASS. `scrutineer` additionally hashed the five modified files before and after -the gate run and found them byte-identical, so no gate reformatted the tree. - -One formatting repair was needed after the first `mdtablefix` pass. The rewrap -split a bold span across a line break in `docs/developers-guide.md`, rendering -``**`script:`-only**`` as a stray `**` at end of line followed by -`` `script:`-only** `` on the next. Reworded to drop the bold entirely rather -than fight the wrapper. Recorded because it is the second time in this plan that -`mdtablefix --wrap` has damaged emphasis, and it is the reason the file is -worth reading back after each automated rewrap rather than trusting exit 0. - -`docs/netsuke-design.md:290-291` was deliberately **not** edited, per the plan: -it is the canonical wording the other three converge on. +### 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. Landed as `3594b568`. `make check-fmt` and `make markdownlint` From 5e22c4e79f5d65c68901797ea7b3c38f7f2c0453 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 20:18:30 +0200 Subject: [PATCH 13/24] Document and test the README placeholder contract Publish the command and script marker boundary, path quoting, and backtick and shlex limitations with three executable README examples. Cover the uniform placeholder set and rejection boundaries in seven test groups, including a real Ninja build and localized JSON failure. Reconcile the living plan with ADR-034 and record red-to-green evidence. --- README.md | 119 +++++++++- docs/developers-guide.md | 30 ++- ...-command-placeholder-contract-in-readme.md | 208 +++++++++++------- tests/documentation_examples_tests.rs | 3 + tests/readme_security_tests.rs | 173 +++++++++++++++ 5 files changed, 439 insertions(+), 94 deletions(-) create mode 100644 tests/readme_security_tests.rs diff --git a/README.md b/README.md index 5a731f9ff..b7fdd9b31 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,117 @@ 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). + +| Form | `command:` | `script:` | +| --------------------------------------------------- | ---------- | --------- | +| `{{ ins }}` | yes | yes | +| `{{ outs }}` | yes | yes | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +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 +329,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/docs/developers-guide.md b/docs/developers-guide.md index 85cc47ba3..d4f84a252 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -3696,16 +3696,26 @@ 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 generation helper composes +manifest rendering, graph lowering, and the POSIX Ninja backend. Both helpers +stay local to this integration target: other callers reuse the shared loader +and production APIs directly. The tests inspect shell-variable preservation, +path quoting, and rejection boundaries, and execute the accepted build with +real Ninja. JSON mode exposes the rejected example's underlying error cause. The first-run README and user's guide examples also run through the `rstest-bdd` scenarios in `tests/features/documentation_examples.feature`. 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 index a36b7b0b3..5297f36a1 100644 --- 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 @@ -25,11 +25,12 @@ 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. -Today that boundary is implemented, proved with Kani and Proptest, 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. +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: @@ -39,22 +40,20 @@ After this change: 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. -- Two of the README's claims are executable: a manifest that Netsuke accepts, - and a manifest that Netsuke rejects with a named diagnostic. Both run in the +- 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. -- Three documents that currently misstate the contract are corrected — - `docs/developers-guide.md`, `docs/users-guide.md`, and - `docs/formal-verification-methods-in-netsuke.md`. All three say, without - qualifying it to command recipes, that literal `$in` and `$out` are left - alone. In a `script:` recipe they are not. +- 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::placeholder_rewriting_differs_by_recipe_kind`, +`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 @@ -93,8 +92,8 @@ multi-line shell script). Netsuke processes these in three stages: `$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 (in scripts -only) `$in` and `$out`. *Internal token* means the `INS_TOKEN` /`OUTS_TOKEN` +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 @@ -401,17 +400,24 @@ workaround. Stop and escalate when any threshold is reached. Do not work around them. -- **Scope.** More than eighteen files changed, or more than 1200 net added - lines across the whole plan. The expected set is sixteen paths: `README.md`; - the six translations; `docs/adr-027-command-placeholder-contract.md`; +- **Scope.** More than eighteen implementation files changed, or more than 1600 + net added implementation lines, excluding this living plan. The user + authorized the remaining three milestones on 2026-09-23 while explicitly + identifying the existing 2445-line diff, including 2075 plan lines. That + authorization supersedes the earlier whole-plan 1200-line ceiling for the + named work. The expected set is sixteen paths: `README.md`; the six + translations; `docs/adr-027-command-placeholder-contract.md`; `docs/contents.md`; `docs/developers-guide.md`; `docs/formal-verification-methods-in-netsuke.md`; `docs/users-guide.md`; `docs/repository-layout.md`; `docs/roadmap.md`; - `tests/documentation_examples_tests.rs`; and one new test file. This plan - document itself is a seventeenth, and `src/ir/cmd_interpolate/mod.rs` a - possible eighteenth if its doc comment needs correcting. - - The line ceiling is deliberately generous because the README section is + `tests/documentation_examples_tests.rs`; and one new test file. The remaining + work also includes `scripts/check-readme-parity.sh`; the production module + requires no edit. The implementation stays within these named + responsibilities. + + The revised implementation line ceiling allows the known ADR and tests. The + original estimate below omitted executable-fence lines from the translations. + The line allowance is deliberately generous because the README section is written seven times. A realistic estimate is 70-100 lines of section prose per edition (490-700 in total), 150-250 for the ADR, 150-220 for the test file, and 30-50 for the index, roadmap, and correction edits: 820-1220. A @@ -601,8 +607,8 @@ Assumptions relied upon, not verified here: 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: the twelve cells formed by `{{ ins }}`, `{{ outs }}`, `$in`, `$out`, - `$ins`, and `$input`, each crossed with `command:` and `script:`. +- 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 @@ -751,37 +757,17 @@ new evidence. ### EP-M2 — internal documents corrected -This milestone deliberately precedes the README section. Landing the README -first would create a plateau at which `README.md` and `docs/users-guide.md` -state opposite things about `$in` in a `script:`, and `D-SCOPE` already argues -that such a state is exactly the ambiguity 4.4.1 exists to remove. The two -milestones are independent, so ordering costs nothing and removes the -contradiction window entirely. - -- Outcome: three documents state the per-recipe-kind placeholder set - correctly. - - `docs/developers-guide.md:3187-3190` (§*Command interpolation contract*), - which currently says "Literal shell variables such as `$in`, `$out`, - `$ins`, and `$outs` remain unchanged" without qualifying it to command - recipes. - - `docs/users-guide.md:1697-1698`, which says "`{{ ins }}` and `{{ outs }}` - are the only Netsuke markers for input and output paths", and - `docs/users-guide.md:1713-1715`, which tells authors to replace `$in` and - `$out` without saying that the `script:` forms still resolve. - - `docs/formal-verification-methods-in-netsuke.md:265-266` (§*Command - placeholder contract*) and `:64-66` (§*Kani for command interpolation*), - both of which say literal `$in` and `$out` "remain shell variables" - unqualified. Also record in §*Command placeholder contract* that - `FV-CPC-Q1` through `FV-CPC-Q3` are now answered, pointing at ADR-027, and - correct the `[^8]` footnote path from `src/ir/cmd_interpolate.rs` to - `src/ir/cmd_interpolate/mod.rs`. - - `docs/netsuke-design.md:290-291` already states this correctly — "standalone - `$in` and `$out` resolve only in scripts" — and must **not** be changed. It - is the canonical wording the other three should converge on. Where a doc - comment in `src/ir/cmd_interpolate/mod.rs` is itself inaccurate about - scripts, correct the comment text only; no code. - +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 @@ -959,8 +945,9 @@ confirm that no sentence could be read as a guarantee Netsuke does not make. 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. Its first line must be `mod documentation_examples;` — that -declaration is load-bearing beyond the import, because +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 @@ -1248,12 +1235,14 @@ 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 }}`, - `{{ outs }}`, and the script-only `$in` and `$out`. It says plainly that a - backtick pair the author wrote is executed by the shell. It says which recipe - kinds and which shells the `shlex` gate covers. -- Copy the section's rejected example into a `Netsukefile` and run `netsuke`. - The output contains `Invalid command interpolation:` and no build runs. + `## Security and command interpolation` section. It names `{{ ins }}` and + `{{ outs }}` as markers, and dollar-prefixed forms as shell variables in both + recipe kinds. It says plainly that a backtick pair the author wrote is + executed by the shell. 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. @@ -1263,7 +1252,7 @@ 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`, - `placeholder_rewriting_differs_by_recipe_kind`, + `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`, @@ -1290,7 +1279,7 @@ Quality method: the gate sequence in `Concrete steps` step 10, delegated to ## 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 two negative +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. @@ -1316,7 +1305,7 @@ mod documentation_examples; use documentation_examples::{documented_example, manifest_workspace}; ``` -Its first line must be `mod documentation_examples;`. That declaration is +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]]` @@ -1326,7 +1315,7 @@ covers the file. The cases it must define, by name: - `documented_safe_placeholder_manifest_builds` -- `placeholder_rewriting_differs_by_recipe_kind` +- `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` @@ -1368,6 +1357,28 @@ 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-M1 — complete (`3594b568`) The ADR, its index entry, and the design-document cross-reference are written, @@ -1500,6 +1511,17 @@ shell variable in *both* recipe kinds is now correct and needs no edit. ## 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: @@ -1659,13 +1681,28 @@ shell variable in *both* recipe kinds is now correct and needs no edit. ## Decision log -- Decision `D1`: 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. - Awaiting approval. +- 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 @@ -1675,7 +1712,7 @@ shell variable in *both* recipe kinds is now correct and needs no edit. 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. Awaiting approval. + 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 @@ -1686,7 +1723,7 @@ shell variable in *both* recipe kinds is now correct and needs no edit. 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. Awaiting approval. + 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 @@ -1975,7 +2012,16 @@ knowingly deferred. ## Artefacts and notes -To be populated during implementation. At minimum, retain: +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. + +At minimum, retain: - the red transcript from `Concrete steps` step 6, showing both missing-identifier failures and the registry-drift failure; @@ -2081,6 +2127,6 @@ 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. -Remaining work: all of it; the plan awaits approval before any implementation -begins. `D1-LEGACY` additionally awaits the owner's confirmation, though it is -written so that either answer leaves the documentation honest. +Remaining work on 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/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_security_tests.rs b/tests/readme_security_tests.rs new file mode 100644 index 000000000..311fc240a --- /dev/null +++ b/tests/readme_security_tests.rs @@ -0,0 +1,173 @@ +//! Pin the README's POSIX command-interpolation contract to executable examples. + +#![cfg(unix)] + +mod documentation_examples; + +use anyhow::{Context, Result}; +use documentation_examples::{assert_success, documented_example, manifest_workspace}; +use googletest::{assert_that, matchers::contains_substring}; +use mockable::{DefaultEnv, Env}; +use netsuke::{ + ir::BuildGraph, + manifest, + ninja_gen::{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) +} + +/// Compile a manifest through rendering, lowering, and the POSIX Ninja backend. +/// +/// 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. +fn generate_posix(source: &str) -> Result { + let manifest = manifest::from_str(source)?; + let graph = BuildGraph::from_manifest_for_shell(&manifest, RecipeShell::Posix)?; + generate_with_shell(&graph, RecipeShell::Posix).map_err(Into::into) +} + +#[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] +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(()) +} From 31ed9e6809ae3b1407202f57527aa6ac86a89d1a Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 20:26:53 +0200 Subject: [PATCH 14/24] Record README contract gates and mutation controls Retain the full EP-M3 gate results and the three post-commit controls. Record the detected script-placeholder and parity regressions, with production source restored before milestone review. --- ...-command-placeholder-contract-in-readme.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) 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 index 5297f36a1..f15996573 100644 --- 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 @@ -1379,6 +1379,38 @@ 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. Milestone review is pending; no translation work +starts until CodeRabbit concerns have been dispositioned. + ### EP-M1 — complete (`3594b568`) The ADR, its index entry, and the design-document cross-reference are written, @@ -2021,6 +2053,20 @@ EP-M3 red evidence (2026-09-23): `--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 From cdb98ac815573dbbf55c2bfa8bb56c599e7cb1f3 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 20:56:19 +0200 Subject: [PATCH 15/24] Translate the README command contract Carry the security boundaries into all six translated READMEs and preserve identical executable examples. Add a manual structural parity checker and document the recurring translation obligation. Record full gate results and the completed EP-M3 review in the plan. --- README.de.md | 123 +++++++++++++++++- README.es.md | 122 ++++++++++++++++- README.fr.md | 123 +++++++++++++++++- README.ja.md | 117 ++++++++++++++++- README.pt-BR.md | 118 ++++++++++++++++- README.zh-CN.md | 101 +++++++++++++- ...-command-placeholder-contract-in-readme.md | 66 +++++++--- docs/repository-layout.md | 5 + scripts/check-readme-parity.sh | 28 ++++ 9 files changed, 764 insertions(+), 39 deletions(-) create mode 100755 scripts/check-readme-parity.sh diff --git a/README.de.md b/README.de.md index 417623413..da55d8629 100644 --- a/README.de.md +++ b/README.de.md @@ -170,6 +170,121 @@ 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 umgeschrieben). + +| Form | `command:` | `script:` | +| --------------------------------------------------- | ---------- | --------- | +| `{{ ins }}` | yes | yes | +| `{{ outs }}` | yes | yes | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +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 +320,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..4d71c5ce0 100644 --- a/README.es.md +++ b/README.es.md @@ -172,6 +172,120 @@ 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). + +| Forma | `command:` | `script:` | +| --------------------------------------------------- | ---------- | --------- | +| `{{ ins }}` | yes | yes | +| `{{ outs }}` | yes | yes | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +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 +325,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..d19ca7c6a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -172,6 +172,121 @@ 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). + +| Forme | `command:` | `script:` | +| --------------------------------------------------- | ---------- | --------- | +| `{{ ins }}` | yes | yes | +| `{{ outs }}` | yes | yes | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +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 +324,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..7d0fd0958 100644 --- a/README.ja.md +++ b/README.ja.md @@ -149,6 +149,117 @@ 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 | yes | +| `{{ outs }}` | yes | yes | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +既存の `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 +284,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.pt-BR.md b/README.pt-BR.md index 087d1850a..1bb4f7994 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -168,6 +168,116 @@ 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). + +| Forma | `command:` | `script:` | +| --------------------------------------------------- | ---------- | --------- | +| `{{ ins }}` | yes | yes | +| `{{ outs }}` | yes | yes | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +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. Uma versão futura aceitar texto antes +rejeitado não é 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 +311,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..c2be1aa51 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -140,6 +140,102 @@ 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 | yes | +| `{{ outs }}` | yes | yes | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +例如,若已有 `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 +259,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/execplans/4-4-1-document-command-placeholder-contract-in-readme.md b/docs/execplans/4-4-1-document-command-placeholder-contract-in-readme.md index f15996573..cd6df5d77 100644 --- 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 @@ -299,7 +299,7 @@ Skills to load: Upstream artefacts, at the revisions current on branch `4-4-1-document-command-placeholder-contract-in-readme`, based on `origin/main` -at commit `81d44f89`: +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` @@ -465,13 +465,12 @@ Stop and escalate when any threshold is reached. Do not work around them. at Stage A both target this. The existing README already says "it is not a sandbox"; keep that framing and strengthen it. -- **Risk: the two new README examples make the test suite slower or flaky.** - The e2e harness needs `ninja` on `PATH` and skips when absent - (`tests/documentation_examples_e2e_tests.rs:46-50`). Severity: low. - Likelihood: low. Mitigation: the rejection example needs no build at all — it - fails during IR lowering, before Ninja is invoked — so it can be a plain - integration test with no external tool. Only the accepting example touches - Ninja, and it reuses the existing skip guard. +- **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: @@ -495,12 +494,9 @@ Stop and escalate when any threshold is reached. Do not work around them. genuinely absent, record that `github-actions-lint` did not run and say so in the evidence rather than reporting a clean gate. -- **Risk: the ADR number collides.** `docs/` already contains duplicate - numbers at 003, 004, and 014. Severity: low. Likelihood: low. Mitigation: the - highest existing number is 020 - (`docs/adr-020-release-admission-observability.md`). Use 021 and re-run - `ls docs/adr-*` immediately before creating the file, in case another branch - has landed one. +- **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 @@ -1408,8 +1404,44 @@ file copies, with clean `git status --porcelain` between mutations: failed because even parity was rejected (12 passed, 13 failed). The five executable obligations are discharged. The structural-parity -obligation remains for EP-M4. Milestone review is pending; no translation work -starts until CodeRabbit concerns have been dispositioned. +obligation remains for EP-M4. The committed EP-M3 review completed with zero +findings against `24dfc3fe`: +`coderabbit review --agent --type 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 — in progress (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-M1 — complete (`3594b568`) @@ -1535,7 +1567,7 @@ shell variable in *both* recipe kinds is now correct and needs no edit. comment corrected. Precedes the README deliberately. All six gates pass; the step-5 grep returns *no* survivors at all. -- [ ] EP-M3 — README section and three executable examples; red observed +- [x] EP-M3 — README section and three executable examples; red observed before green; three negative controls recorded after the commit. - [ ] EP-M4 — six translated READMEs regain structural parity; `docs/repository-layout.md` records the recurring obligation. 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/scripts/check-readme-parity.sh b/scripts/check-readme-parity.sh new file mode 100755 index 000000000..818f08bd2 --- /dev/null +++ b/scripts/check-readme-parity.sh @@ -0,0 +1,28 @@ +#!/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 ' + /^[[:space:]]*(```|~~~)/ { fenced = !fenced; next } + !fenced && /^#{1,6} / { + levels = levels separator $1 + 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" From 2800c8af1823804137408b2c93621db99a915d84 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 21:15:58 +0200 Subject: [PATCH 16/24] Make README parity checks portable Avoid awk interval expressions when recognizing headings, retaining the six-level bound. Clarify the Portuguese compatibility statement with an explicit conditional and record both review findings. --- README.pt-BR.md | 6 +++--- ...-document-command-placeholder-contract-in-readme.md | 10 ++++++++++ scripts/check-readme-parity.sh | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.pt-BR.md b/README.pt-BR.md index 1bb4f7994..7fed9a041 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -266,9 +266,9 @@ 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. Uma versão futura aceitar texto antes -rejeitado não é alteração incompatível; rejeitar texto antes aceito é defeito a -registrar no changelog, não uma mudança de política. +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 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 index cd6df5d77..bd501f17f 100644 --- 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 @@ -1443,6 +1443,16 @@ passed), `markdownlint`, and `nixie`. Logs: `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. Gates and a follow-up review must pass before EP-M5. + ### EP-M1 — complete (`3594b568`) The ADR, its index entry, and the design-document cross-reference are written, diff --git a/scripts/check-readme-parity.sh b/scripts/check-readme-parity.sh index 818f08bd2..30265c73e 100755 --- a/scripts/check-readme-parity.sh +++ b/scripts/check-readme-parity.sh @@ -10,7 +10,7 @@ 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 ' /^[[:space:]]*(```|~~~)/ { fenced = !fenced; next } - !fenced && /^#{1,6} / { + !fenced && /^#+ / && length($1) <= 6 { levels = levels separator $1 separator = "," count++ From a5cf2f035119637965214d0a9c6efb7a99d695c0 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 21:28:55 +0200 Subject: [PATCH 17/24] Respect matching fences in README parity checks Track the opening fence delimiter and width so nested example fences do not expose fake headings. Require valid matching closers and record the reproduced failure and repair in the execution plan. --- ...-command-placeholder-contract-in-readme.md | 18 +++++++++++++-- scripts/check-readme-parity.sh | 23 ++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) 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 index bd501f17f..19a7fc143 100644 --- 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 @@ -1406,7 +1406,7 @@ file copies, with clean `git status --porcelain` between mutations: 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 --type committed --base-commit 24dfc3fe`, log +`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. @@ -1451,7 +1451,21 @@ 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. Gates and a follow-up review must pass before EP-M5. +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. Gates and a follow-up review must pass before EP-M5. ### EP-M1 — complete (`3594b568`) diff --git a/scripts/check-readme-parity.sh b/scripts/check-readme-parity.sh index 30265c73e..12f157795 100755 --- a/scripts/check-readme-parity.sh +++ b/scripts/check-readme-parity.sh @@ -9,7 +9,28 @@ 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 ' - /^[[:space:]]*(```|~~~)/ { fenced = !fenced; next } + { + 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 && /^#+ / && length($1) <= 6 { levels = levels separator $1 separator = "," From 1799474fffd4af2d51c812819b8dddfbd8249c93 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 21:41:31 +0200 Subject: [PATCH 18/24] Recognize indented README headings Count valid ATX headings after up to three leading spaces, including tab separators and empty headings. Retain the six-level bound and fenced-example exclusion. Record the reproduced review concern. --- ...ument-command-placeholder-contract-in-readme.md | 14 +++++++++++++- scripts/check-readme-parity.sh | 12 ++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) 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 index 19a7fc143..9f72d99b0 100644 --- 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 @@ -1465,7 +1465,19 @@ 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. Gates and a follow-up review must pass before EP-M5. +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. Gates and follow-up review remain required before +EP-M5. ### EP-M1 — complete (`3594b568`) diff --git a/scripts/check-readme-parity.sh b/scripts/check-readme-parity.sh index 12f157795..e68ae7cd7 100755 --- a/scripts/check-readme-parity.sh +++ b/scripts/check-readme-parity.sh @@ -31,10 +31,14 @@ for file in README.md README.de.md README.es.md README.fr.md \ next } } - !fenced && /^#+ / && length($1) <= 6 { - levels = levels separator $1 - separator = "," - count++ + !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") From bce3b2a00b0595e15043a0663da613c846d9ed58 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 21:57:36 +0200 Subject: [PATCH 19/24] Normalize CRLF in README parity checks Remove one trailing carriage return before parsing fences and headings so line-ending differences do not hide sections. Record the reproduced failure and disposition translated README voice suggestions against the repository style guide and locale glossary. --- ...-command-placeholder-contract-in-readme.md | 24 +++++++++++++++++-- scripts/check-readme-parity.sh | 1 + 2 files changed, 23 insertions(+), 2 deletions(-) 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 index 9f72d99b0..b01afacf3 100644 --- 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 @@ -1476,8 +1476,28 @@ 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. Gates and follow-up review remain required before -EP-M5. +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 remain required before EP-M5. ### EP-M1 — complete (`3594b568`) diff --git a/scripts/check-readme-parity.sh b/scripts/check-readme-parity.sh index e68ae7cd7..a39f13b6e 100755 --- a/scripts/check-readme-parity.sh +++ b/scripts/check-readme-parity.sh @@ -10,6 +10,7 @@ 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) From 636bf523661637b200ddc65d3a901bdc1277ba85 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 22:09:24 +0200 Subject: [PATCH 20/24] Close the README contract roadmap item Mark roadmap 4.4.1 and its four criteria complete. Link the published README section from ADR-027 and the formal verification guide, and reconcile the execution plan with the delivered scope and review record. --- docs/adr-027-command-placeholder-contract.md | 9 +-- ...-command-placeholder-contract-in-readme.md | 68 +++++++++++++++---- .../formal-verification-methods-in-netsuke.md | 9 +-- docs/roadmap.md | 13 ++-- 4 files changed, 73 insertions(+), 26 deletions(-) diff --git a/docs/adr-027-command-placeholder-contract.md b/docs/adr-027-command-placeholder-contract.md index d8d413aaf..377334211 100644 --- a/docs/adr-027-command-placeholder-contract.md +++ b/docs/adr-027-command-placeholder-contract.md @@ -3,10 +3,11 @@ ## 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 will -carry them to users in a new `README.md` section, *Security and command -interpolation*, in every translated README; that section is planned work, not -yet published, and this record is authoritative until it lands. +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 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 index b01afacf3..86049b04f 100644 --- 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 @@ -1411,7 +1411,7 @@ findings against `24dfc3fe`: 56/56 before that review; the evidence-only commit `31ed9e68` passed formatting, Markdown, and Mermaid checks. EP-M4 may proceed. -### EP-M4 — in progress (2026-09-23) +### 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 @@ -1497,7 +1497,39 @@ 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 remain required before EP-M5. +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 — in progress (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 and whole-branch CodeRabbit review are pending. ### EP-M1 — complete (`3594b568`) @@ -1616,16 +1648,15 @@ 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. Landed as `3594b568`. `make check-fmt` and `make markdownlint` - pass. Awaiting the milestone CodeRabbit pass. -- [x] EP-M2 — `docs/developers-guide.md`, `docs/users-guide.md`, - `docs/formal-verification-methods-in-netsuke.md`, and any inaccurate doc - comment corrected. Precedes the README deliberately. All six gates pass; - the step-5 grep returns *no* survivors at all. + 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. -- [ ] EP-M4 — six translated READMEs regain structural parity; +- [x] EP-M4 — six translated READMEs regain structural parity; `docs/repository-layout.md` records the recurring obligation. - [ ] EP-M5 — roadmap 4.4.1 marked done; full gate sequence green. @@ -2120,7 +2151,18 @@ shell variable in *both* recipe kinds is now correct and needs no edit. ## Outcomes & retrospective -Not yet started. To be completed at EP-M5. +EP-M1 through EP-M4 are complete. The README contract is implemented in all +seven editions, with three executable examples, 25 parameterized security +cases, three discriminating controls, and a manual parity checker. No +production behaviour or dependency changed. All six verification obligations +are discharged. EP-M5 awaits the final full gate sequence and full-branch +review before the plan is marked complete. + +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. Before setting this plan to `COMPLETE`, reconcile each entry in `Surprises & discoveries` against the artefacts in `Conformance basis`: confirm @@ -2261,6 +2303,6 @@ 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. -Remaining work on 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`. +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 705e090f5..cb59d2c41 100644 --- a/docs/formal-verification-methods-in-netsuke.md +++ b/docs/formal-verification-methods-in-netsuke.md @@ -274,10 +274,11 @@ that fails the current `shlex` guard — run only on `command:` text; scripts ma legitimately contain heredocs and other syntax `shlex` cannot model.[^8] PowerShell treats a backtick as an escape. -**Settled.** 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](adr-027-command-placeholder-contract.md). The three questions this -section raised are now answered: +**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 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). From cf985eb1e36817da3f2cbf03764f19f6f50c2164 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 22:22:59 +0200 Subject: [PATCH 21/24] Clarify backtick limits and plan gate failures Distinguish active POSIX backticks from literal single-quoted text in the contract records and acceptance instructions. Keep ADR creation and revision dates in their respective fields. Make the plan's Bash pipelines preserve failures and stop subsequent gates, with an explicit guard for the expected-red test run. --- docs/adr-027-command-placeholder-contract.md | 9 +-- ...-command-placeholder-contract-in-readme.md | 64 +++++++++++++------ .../formal-verification-methods-in-netsuke.md | 5 +- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/docs/adr-027-command-placeholder-contract.md b/docs/adr-027-command-placeholder-contract.md index 377334211..876ab0ae5 100644 --- a/docs/adr-027-command-placeholder-contract.md +++ b/docs/adr-027-command-placeholder-contract.md @@ -20,7 +20,7 @@ against the implementation on the revision date. ## Date -2026-09-19, revised 2026-09-23 +2026-09-19 ## Context and problem statement @@ -111,9 +111,10 @@ 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 does not inspect backticks the author -wrote. A balanced pair is passed through to the shell and executed as command -substitution. The single exception is that `quote_double_quoted_path` +**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. 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 index 86049b04f..e707db7bc 100644 --- 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 @@ -150,11 +150,13 @@ and `D1-RESOLVED` in `Decision log` for what replaced `D1-LEGACY`. 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. A balanced backtick pair - containing text the author typed reaches the shell and is executed as command - substitution. The only exception is that `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. +- 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 @@ -897,9 +899,9 @@ ADR-027 must say. 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 does not inspect backticks the author - wrote. A balanced pair is passed to the shell and executed as command - substitution. + - **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 @@ -1075,7 +1077,8 @@ Run everything from the repository root, entry, and add the design-document reference. Commit as EP-M1. **Done** as `3594b568`. - ```sh + ```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 ``` @@ -1121,9 +1124,13 @@ Run everything from the repository root, 6. Add `tests/readme_security_tests.rs` and the three identifiers to `EXPECTED_EXAMPLE_IDS`. Observe red. - ```sh - cargo nextest run --test readme_security_tests --test documentation_examples_tests \ - 2>&1 | tee /tmp/red-netsuke-$(git branch --show-current).out + ```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`, @@ -1135,7 +1142,8 @@ Run everything from the repository root, 7. Write the README section with all three marked fences. Observe green, then run the full gates and commit as EP-M3. - ```sh + ```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 @@ -1216,10 +1224,12 @@ Run everything from the repository root, Delegate this run to the `scrutineer` sub-agent rather than running it in the planning context. - ```sh + ```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 @@ -1233,9 +1243,10 @@ A reader can verify the outcome without reading any test: `## 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 says plainly that a backtick pair the author wrote is - executed by the shell. It says which recipe kinds and which shells the - `shlex` gate covers. + 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. @@ -1529,7 +1540,24 @@ 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 and whole-branch CodeRabbit review are pending. +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. Only these documentation repairs remain to be gated and reviewed before +completion. ### EP-M1 — complete (`3594b568`) diff --git a/docs/formal-verification-methods-in-netsuke.md b/docs/formal-verification-methods-in-netsuke.md index cb59d2c41..c4a4daf7f 100644 --- a/docs/formal-verification-methods-in-netsuke.md +++ b/docs/formal-verification-methods-in-netsuke.md @@ -289,8 +289,9 @@ questions this section raised are now answered: 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 makes no guarantee about backticks the - author wrote, which the shell will execute. + 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 From db3e3f9ff6b535aef85a7ee94d8590e9479fa330 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 23 Sep 2026 22:35:21 +0200 Subject: [PATCH 22/24] Record completion of the README contract plan Close all five milestones and record the final gate and review evidence. Keep the reviewed finding dispositions and remaining unrelated issues explicit so the completion record matches the delivered branch. --- ...-command-placeholder-contract-in-readme.md | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) 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 index e707db7bc..c255e8a5c 100644 --- 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 @@ -6,7 +6,7 @@ This ExecPlan (execution plan) is a living document. The sections `Constraints`, `Conformance basis`, and `Verification plan` must be kept up to date as work proceeds. -Status: IN PROGRESS +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 @@ -1515,7 +1515,7 @@ commit `bce3b2a0` carries the fix. The final repair review zero findings (`/tmp/coderabbit-netsuke-readme-m4-crlf.out`). All EP-M4 concerns are fixed or dispositioned above. -### EP-M5 — in progress (2026-09-23) +### 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 @@ -1556,8 +1556,27 @@ 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. Only these documentation repairs remain to be gated and reviewed before -completion. +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. + +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. ### EP-M1 — complete (`3594b568`) @@ -1686,7 +1705,8 @@ shell variable in *both* recipe kinds is now correct and needs no edit. 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. -- [ ] EP-M5 — roadmap 4.4.1 marked done; full gate sequence green. +- [x] EP-M5 — roadmap 4.4.1 marked done; full gate sequence green; + whole-branch review findings fixed or dispositioned. ## Surprises & discoveries @@ -2179,12 +2199,14 @@ shell variable in *both* recipe kinds is now correct and needs no edit. ## Outcomes & retrospective -EP-M1 through EP-M4 are complete. The README contract is implemented in all +All five milestones are complete. The README contract is implemented in all seven editions, with three executable examples, 25 parameterized security cases, three discriminating controls, and a manual parity checker. No production behaviour or dependency changed. All six verification obligations -are discharged. EP-M5 awaits the final full gate sequence and full-branch -review before the plan is marked complete. +are discharged, roadmap 4.4.1 and its four criteria are checked, and the full +gate sequence passed. Whole-branch review findings are fixed or dispositioned; +the final repair review returned zero findings. The PR title and Lody session +title match the implemented task without the former `Plan:` prefix. The review loop strengthened the manual checker against older `awk`, nested fences, indented headings, and CRLF files. The useful lesson is to validate the From abd1042fdddbcb6156c62dc3d17bbde1f65466cf Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 24 Sep 2026 00:53:29 +0200 Subject: [PATCH 23/24] Scope command validation docs to POSIX and Bash Qualify the developers' guide's command validation statement so it does not imply that PowerShell runs the odd-backtick and shlex checks. Record the verified finding and documentation gate results in the plan. --- docs/developers-guide.md | 6 +++--- ...4-1-document-command-placeholder-contract-in-readme.md | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index d4f84a252..acdb7c706 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -3909,9 +3909,9 @@ the former `script:`-only lowering of `$in` and `$out`, and 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. For -`command:` recipes the POSIX scanner then validates the substituted text: odd -backticks reject the command, and the `shlex` guard also evaluates that +uses backticks as escapes and does not enter that protected region. 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 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 index c255e8a5c..738708535 100644 --- 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 @@ -1571,6 +1571,14 @@ attempt is preserved at 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 From 6a600701a9e60ccd85c272a7c69ac4efee3c15d8 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 24 Sep 2026 01:35:19 +0200 Subject: [PATCH 24/24] Qualify inert markers and test README parity Scope marker expansion to active recipe text across the README editions and contract documentation. Cover preserved comment and heredoc tokens through manifest lowering and real Ninja execution, including the existing rejection of multiline command recipes. Add isolated fixtures for the manual README parity checker, including heading mismatches, fence boundaries, line endings and invocation paths. Record the review findings and successful validation in the ExecPlan. --- README.de.md | 22 +- README.es.md | 22 +- README.fr.md | 24 +- README.ja.md | 17 +- README.md | 22 +- README.pt-BR.md | 22 +- README.zh-CN.md | 21 +- docs/adr-027-command-placeholder-contract.md | 26 +- docs/developers-guide.md | 68 +++-- ...-command-placeholder-contract-in-readme.md | 114 +++++--- tests/readme_parity_tests.rs | 243 ++++++++++++++++++ tests/readme_security_tests.rs | 104 +++++++- 12 files changed, 580 insertions(+), 125 deletions(-) create mode 100644 tests/readme_parity_tests.rs diff --git a/README.de.md b/README.de.md index da55d8629..8f7e06da0 100644 --- a/README.de.md +++ b/README.de.md @@ -195,13 +195,21 @@ ausgewählte Shell sie unverändert erhält; Ninjas eigene Regelvariablen `$in` `$out` werden dadurch nicht verfügbar. Tabelle 1: Zu Eingabe- oder Ausgabepfaden umgeschriebene Formen (`yes` -bedeutet: wird umgeschrieben). - -| Form | `command:` | `script:` | -| --------------------------------------------------- | ---------- | --------- | -| `{{ ins }}` | yes | yes | -| `{{ outs }}` | yes | yes | -| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | +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 diff --git a/README.es.md b/README.es.md index 4d71c5ce0..ea47ebf1a 100644 --- a/README.es.md +++ b/README.es.md @@ -199,13 +199,21 @@ 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). - -| Forma | `command:` | `script:` | -| --------------------------------------------------- | ---------- | --------- | -| `{{ ins }}` | yes | yes | -| `{{ outs }}` | yes | yes | -| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | +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: diff --git a/README.fr.md b/README.fr.md index d19ca7c6a..2513ee00a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -199,13 +199,23 @@ 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). - -| Forme | `command:` | `script:` | -| --------------------------------------------------- | ---------- | --------- | -| `{{ ins }}` | yes | yes | -| `{{ outs }}` | yes | yes | -| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | +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 : diff --git a/README.ja.md b/README.ja.md index 7d0fd0958..d0fa99ffa 100644 --- a/README.ja.md +++ b/README.ja.md @@ -177,11 +177,18 @@ ______________________________________________________________________ 表1: 入力または出力パスに書き換えられる形式 (`yes` は書き換えられることを示します)。 -| 形式 | `command:` | `script:` | -| --------------------------------------------------- | ---------- | --------- | -| `{{ ins }}` | yes | yes | -| `{{ outs }}` | yes | yes | -| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | +| 形式 | `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` が空でないことを確認します。 diff --git a/README.md b/README.md index b7fdd9b31..d8513f1fd 100644 --- a/README.md +++ b/README.md @@ -210,13 +210,21 @@ 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). - -| Form | `command:` | `script:` | -| --------------------------------------------------- | ---------- | --------- | -| `{{ ins }}` | yes | yes | -| `{{ outs }}` | yes | yes | -| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | +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: diff --git a/README.pt-BR.md b/README.pt-BR.md index 7fed9a041..8f9d0e233 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -193,13 +193,21 @@ 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). - -| Forma | `command:` | `script:` | -| --------------------------------------------------- | ---------- | --------- | -| `{{ ins }}` | yes | yes | -| `{{ outs }}` | yes | yes | -| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | +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: diff --git a/README.zh-CN.md b/README.zh-CN.md index c2be1aa51..0a6fa20fd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -160,13 +160,20 @@ ______________________________________________________________________ Ninja将美元符号加倍,使所选shell收到的内容保持不变;它不会暴露Ninja 自身的规则变量 `$in` 和 `$out`。 -表1:会被改写为输入或输出路径的形式(`yes` 表示会改写)。 - -| 形式 | `command:` | `script:` | -| --------------------------------------------------- | ---------- | --------- | -| `{{ ins }}` | yes | yes | -| `{{ outs }}` | yes | yes | -| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | +表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` 是否非空: diff --git a/docs/adr-027-command-placeholder-contract.md b/docs/adr-027-command-placeholder-contract.md index 876ab0ae5..f2d055a81 100644 --- a/docs/adr-027-command-placeholder-contract.md +++ b/docs/adr-027-command-placeholder-contract.md @@ -65,25 +65,33 @@ behaviour are promises. This record settles both. ### The placeholder set is uniform across recipe kinds -`{{ 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 to interpret, in both -recipe kinds. The Ninja backend doubles the dollar so the shell receives the -text unchanged. +`{{ 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 that exists - only between manifest rendering and interpolation; +- 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 resolve the same set. +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 diff --git a/docs/developers-guide.md b/docs/developers-guide.md index acdb7c706..68ebc4b34 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -392,14 +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, - identically in both recipe kinds. 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. + 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. 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 @@ -3710,12 +3712,24 @@ 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 generation helper composes -manifest rendering, graph lowering, and the POSIX Ninja backend. Both helpers -stay local to this integration target: other callers reuse the shared loader -and production APIs directly. The tests inspect shell-variable preservation, -path quoting, and rejection boundaries, and execute the accepted build with -real Ninja. JSON mode exposes the rejected example's underlying error cause. +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`. @@ -3881,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 @@ -3909,9 +3924,14 @@ the former `script:`-only lowering of `$in` and `$out`, and 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. 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 +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 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 index 738708535..067800214 100644 --- 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 @@ -403,29 +403,23 @@ workaround. 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. The user - authorized the remaining three milestones on 2026-09-23 while explicitly - identifying the existing 2445-line diff, including 2075 plan lines. That - authorization supersedes the earlier whole-plan 1200-line ceiling for the - named work. The expected set is sixteen paths: `README.md`; the six - translations; `docs/adr-027-command-placeholder-contract.md`; - `docs/contents.md`; `docs/developers-guide.md`; - `docs/formal-verification-methods-in-netsuke.md`; `docs/users-guide.md`; - `docs/repository-layout.md`; `docs/roadmap.md`; - `tests/documentation_examples_tests.rs`; and one new test file. The remaining - work also includes `scripts/check-readme-parity.sh`; the production module - requires no edit. The implementation stays within these named - responsibilities. - - The revised implementation line ceiling allows the known ADR and tests. The - original estimate below omitted executable-fence lines from the translations. - The line allowance is deliberately generous because the README section is - written seven times. A realistic estimate is 70-100 lines of section prose - per edition (490-700 in total), 150-250 for the ADR, 150-220 for the test - file, and 30-50 for the index, roadmap, and correction edits: 820-1220. A - tighter ceiling would fire on the plan's own expected path, which trains an - implementer to ignore the tolerance and destroys its value for the cases that - matter. + 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 @@ -1586,6 +1580,41 @@ 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, @@ -2207,14 +2236,13 @@ shell variable in *both* recipe kinds is now correct and needs no edit. ## Outcomes & retrospective -All five milestones are complete. The README contract is implemented in all -seven editions, with three executable examples, 25 parameterized security -cases, three discriminating controls, and a manual parity checker. No -production behaviour or dependency changed. All six verification obligations -are discharged, roadmap 4.4.1 and its four criteria are checked, and the full -gate sequence passed. Whole-branch review findings are fixed or dispositioned; -the final repair review returned zero findings. The PR title and Lody session -title match the implemented task without the former `Plan:` prefix. +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 @@ -2222,13 +2250,23 @@ 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. -Before setting this plan to `COMPLETE`, reconcile each entry in -`Surprises & discoveries` against the artefacts in `Conformance basis`: confirm -that EP-M2 corrected the Fact A misstatements in all three documents and the -`[^8]` footnote, that `docs/formal-verification-methods-in-netsuke.md` records -`FV-CPC-Q1` through `FV-CPC-Q3` as answered with a pointer to ADR-027, and that -the 4.2.3 status discrepancy is either resolved elsewhere or recorded as -knowingly deferred. +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 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 index 311fc240a..50cf6bfac 100644 --- a/tests/readme_security_tests.rs +++ b/tests/readme_security_tests.rs @@ -4,14 +4,15 @@ mod documentation_examples; -use anyhow::{Context, Result}; +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::{ - ir::BuildGraph, + ast::Recipe, + ir::{BuildGraph, INS_TOKEN, OUTS_TOKEN}, manifest, - ninja_gen::{RecipeShell, generate_with_shell}, + ninja_gen::{NinjaGenError, RecipeShell, generate_with_shell}, }; use pretty_assertions::assert_eq; use rstest::{fixture, rstest}; @@ -26,17 +27,37 @@ fn safe_manifest() -> Result { Ok(documented_example("readme-safe-placeholder-manifest")?.body) } -/// Compile a manifest through rendering, lowering, and the POSIX Ninja backend. +/// 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 manifest = manifest::from_str(source)?; - let graph = BuildGraph::from_manifest_for_shell(&manifest, RecipeShell::Posix)?; - generate_with_shell(&graph, RecipeShell::Posix).map_err(Into::into) + let (_, graph) = lower_recipe_for_shell(source, RecipeShell::Posix)?; + Ok(generate_with_shell(&graph, RecipeShell::Posix)?) } #[rstest] @@ -95,6 +116,75 @@ fn dollar_forms_are_shell_variables_in_both_recipe_kinds( 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")?;