fix: quote or refuse caller values reaching a shell, align bash conventions - #161
Merged
Martin Bens (SpiGAndromeda) merged 14 commits intoAug 27, 2026
Merged
Conversation
The writing-code, writing-tests, and writing-docs skills ran on their universal defaults here, so each invocation re-derived the same project facts and missed conventions no single file reveals. The extension states them once: project.stacks (bash, plus the two independent Python toolchains), code.primitives for the thirteen in-repo wrappers over jq, eval, and command construction, code.footgun_additions for the traps ShellCheck cannot see, tests.frameworks and tests.fixture_sources for the BATS and pytest suites, and docs.surfaces for the tracked documentation surfaces. tests.parallelism, tests.scale_gating, code.comment_enforcement, and docs.style stay unassigned. Each skill's default is either stricter than current practice or unsupported by anything in the tree, and assigning one would encode today's behavior as permanent license. Workflow positions carry what named values cannot: a Pre-Step-3 routing an edit of a template consumer back to templates/, a Pre-Step-1 keeping a template-owned module's tests in plugin-tests/mcp-shared/, and a writing-docs Pre-Step-1 separating runtime instruction Markdown and test fixtures from documentation surfaces. The .gitignore negation lets .claude/extensions/ travel with the repository, matching the existing exception for .claude/hook-contexts/. enabledPlugins drops the stray colon in the commit-message-writer key and no longer enables behavior-diagnostics or prompt-engineering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
validate_tool_arguments ended its jq pipeline with `|| true`, so an `arguments` value that was not a JSON object made `$args | keys` error, the failure was masked, the message stayed empty, and the validator returned success. Every schema constraint (`required`, `additionalProperties`, and `enum`) was skipped for any tool on any of the four servers consuming this template, including shopware-env's `database_reset`, whose run leads to `system:install --drop-database`. A non-object is now rejected by name and type, and a jq failure is now a rejection rather than a skip, since a validator that can't evaluate its input hasn't validated it. The unreadable-tools-list fallback is explicit for the same reason, since errexit is disabled inside these functions and the call site's shape can't be relied on to catch it. The container name was interpolated unquoted into the command string handed to eval at all four `docker exec -i` sites, the plain and npm wrappers in environment.sh, and both compose wrappers. A name carrying a command separator ran as a second command, which shopware-env reaches directly because its lifecycle server takes `docker_service` from a tool argument. Each name now passes through shell_quote_arg at construction, so eval receives one argument. exec_command's header claimed the command is built from trusted config values rather than direct user input, and that path made the claim false. The header now states the actual invariant. The four suites whose subject is a template-owned module move to plugin-tests/mcp-shared/ and source the template directly, so a shared-module regression is no longer reported against a single plugin's copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
resolve_scope interpolated the caller's scope name into a jq filter program, so a name carrying a double quote escaped the string literal and injected jq of its own. A payload of `" // {"cwd":"$(printf PWNED)"} // .scopes."missing` was accepted where an ordinary undeclared name is refused, because the injected alternative made the filter return an object and bypass the declared-scope check, and it left SCOPE_CWD holding the command substitution. wrap_command's native and ddev branches place the workdir inside double quotes at the local level, so eval in exec_command then ran it on the host. Every scope value now reaches jq through --arg and the filters index with $name. The same treatment covers scope_get_tool_field, scope_get_bootstrap, scope_validate, and the install_if_missing lookups in both JS servers, all of which read the same caller-supplied name through SCOPE_NAME.
Each PHP tool parsed its arguments with a `|| echo '{...}'` fallback that turned a jq failure into a complete default object, so malformed JSON from a caller produced a successful run against the wrong targets. All eight sites now refuse and name the payload that failed to parse. phpunit_coverage_gaps had the same shape one level down: its awk call was unguarded, and since handle_tools_call dispatches tool functions on the left of `||`, errexit is off for the whole body, so a parse failure reached the empty-result branch and printed "No files with uncovered lines." with exit 0.
phpunit_coverage_gaps also built its Clover path from LINT_WORKDIR, which under docker-compose carries the literal sentinel `(resolved at call time)`. It now reads the new get_workdir accessor. phpstan_analyze honored only two of the three error_format values its schema declares, so a caller passing `raw` silently got PHPStan's default.
check-phpstan-baseline.sh extracted tool_input.paths with `jq -r`, which strips the quotes from a JSON string and left the following parse reading invalid JSON, exiting 5. The Storefront scripts lint:js:app and lint:js:components, along with their :fix variants, passed the context detector but matched no block rule, and a bare ludtwig reached through a package runner was likewise unblocked. The ludtwig boundary now matches a runner prefix rather than any whitespace, so a command that only mentions the word in an argument stays allowed.
_lsp_exec_direct word-split and glob-expanded its command. It now splits into an array and refuses a binary value containing a line break, which read would silently truncate, or one resolving to an empty command, which exec accepts as a no-op returning success while starting no language server.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tool_plugin_setup checked only that plugin_name was non-empty, since the PascalCase pattern lived inside tool_plugin_create alone, and plugin_namespace was never checked. resolve_lifecycle_env exported docker_service and compose_file straight into DOCKER_CONTAINER, COMPOSE_SERVICE and COMPOSE_FILE. Every one of those values is embedded into a command string that exec_command runs through eval. tool_plugin_setup now applies the same pattern and message as its sibling. The namespace and both compose values pass assert_no_shell_hostile_chars, and every embedded value goes through shell_quote_arg in place of hand-written single quotes. The guard refuses only what quoting can't express, a single quote or a line break, so the quoting is what makes the rest safe. tool_install_dependencies appended each exec_command result to its output without checking any status and printed the accumulated text last, so a failed composer or npm install returned 0 with the error embedded in what read as a normal result, and later steps ran anyway. tool_plugin_create's three steps and tool_plugin_setup's two behaved the same way, so a failed plugin:create still reached plugin:refresh and plugin:install. Each function now checks every step, stops at the first failure, and returns non-zero naming the command that failed. Nothing was going to catch these implicitly, because mcpserver_core.sh dispatches tool functions on the left of `||` and errexit is disabled for the entire body. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tool_get_rules split the comma-separated ids value with an unquoted expansion, so an id carrying a glob character was matched against the filesystem the server runs in, which is the user's project root, and could resolve to unrelated filenames instead of being treated as a rule id. The split now runs with pathname expansion disabled and restores the previous state afterwards, so ids=CONV-* is reported as not found under that literal name. Every echo in the file is now printf, since what it emits is caller-supplied data and captured command output. _render_rules called _strip_frontmatter unguarded. Because mcpserver_core.sh dispatches tool functions on the left of `||`, errexit is off for the whole call graph, so a failure to strip a rule's frontmatter produced an empty body that was concatenated into the output as though the rule had rendered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two validation scripts broke in opposite environments, which is why both survived review. `((failed++))` returns non-zero when the pre-increment value is 0, so under set -e on bash 5, which is what CI runs, the first failing check killed validate-issue-templates.sh and skipped every remaining check and the summary. Unguarded `"${arr[@]}"` on an empty array aborts under set -u on bash 3.2, which is macOS /bin/bash, and command_issue.yml's dropdown is currently empty, so the script aborts locally before printing anything. Counters are now `failed=$((failed + 1))` and array expansions use the `${arr[@]+...}` guard the sibling scripts already use.
extract_changelog_version piped grep into sed, which returns 1 for a CHANGELOG carrying no version header and aborted update-versions.sh for every plugin. It now returns empty with status 0, the contract update_plugin_changelog already handles by warning and writing. validate_dropdown compared option sets by joining on a space, which is not injective over elements containing spaces, and now compares line-wise with diff.
extract_marketplace_version had no callers anywhere in the repo and is removed. get_plugin_source_dir is renamed to _get_plugin_source_dir with its three internal call sites updated, since nothing outside the file used it. Each SCRIPT_DIR assignment redirects its inner cd to /dev/null so a set CDPATH can't put a directory name on stdout, and the SC2064 disable in validate-template-sync.sh carries a justification naming why immediate expansion is intended.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
plugin-tests/AGENTS.md documented make_hook_input, which has never existed in this repo, and named .github/workflows/test-hooks.yml, which does not exist either. It now documents run_hook and assert_hook_blocks, both defined in plugin-tests/test_helper/common_setup.bash, and names ci.yml, the workflow that actually runs the suite. plugin-tests/README.md listed two of the seven directories under plugin-tests/ and placed four suites under dev-tooling/ that now live in mcp-shared/. Its CI line described a narrower trigger set than ci.yml declares. The directory tree is rebuilt from what is on disk, the trigger paths are enumerated from the workflow, and ugrep joins the dependency list, since CI installs it in the same job that runs the suite and sweep.bats exercises a script that calls it. templates/README.md omitted two consumer relationships that CI enforces. test-writing consumes mcp-shared/mcpserver_core.sh, and docker-compose.sh was absent from the table entirely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An independent review of the preceding six commits found six defects in them. `handle_tools_call` derived its arguments object with `jq -c '.arguments // {}'`, and `//` treats a present `null` and a present `false` as absent, so both arrived as `{}` and never reached the non-object rejection those commits added. It now uses `has("arguments")`. On the MCP path `process_request` gates the whole request through `jq -e '.'`, so this is defense in depth for direct invocation rather than a live remote-input fix, and the header says so.
The ludtwig runner allow-list matched only bare runner forms, so `composer exec -- ludtwig`, `npx -y ludtwig` and `pnpm dlx -- ludtwig` passed unblocked. `composer exec --` is the form Composer requires when the invoked binary takes its own options, which left the most likely real invocation as the one that escaped. Two of the four new Storefront block rules had no test, and two of the three new allow tests never reached the block rule at all, so deleting the rule outright would have kept them green.
The id split bracketed its loop with `set -f` and a bare `set +f`, which forces globbing back ON rather than restoring what the caller had. It now uses `local -`. The accompanying test calls the function directly rather than through bats `run`, because `run` is a subshell and the leak wouldn't be observable through it.
The `CONV-*` regression test passed against the code it was meant to pin: bats never chdirs, and nothing at the repo root matches `CONV-*`, so the glob had nothing to expand to. It now manufactures the hazard, creating decoy files and running with the process cwd among them, which is the real shape of the defect since the server's cwd is the user's project root.
`exec_command`'s header described the wrapping for three of five environments. docker, docker-compose and vagrant embed the command in a single-quoted remote string; native has no remote shell. ddev emits bare argv, so the local eval consumes the escaping and ddev re-parses in the container. A value reaching a ddev command is parsed twice while shell_quote_arg escapes for one. The header now states this per branch and names ddev as an open gap rather than implying coverage.
The catalog count assertion allowed six rules to be deleted silently, and the extension file asserted that every tracked AGENTS.md opens with `@README.md`, which holds for ten of thirteen. Three open with a heading or a prose blockquote instead, deliberately, and the extension is loaded into the writing-docs workflow where the false invariant would have steered an edit into normalizing them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parse_paths_json decoded with `jq -r '.[]'` and read the result back through a line-oriented loop, so a newline inside one element was indistinguishable from the separator between elements, and assert_no_shell_hostile_chars ran after the split and saw two newline-free fragments. One element of `src/app\n.` produced `"src/app" "."`, and `.` is the whole tree, so on eslint_fix, stylelint_fix and prettier_fix a single-path request wrote across the entire tree and reported success. The top-level newline guard existed in all six PHP tool libs and none of the thirteen JS ones, so the tools whose purpose is path-scoping were the unprotected ones. The refusal now happens before the split. Refusal rather than NUL-delimited decoding because `jq --raw-output0` needs jq 1.7 and this repo declares no jq minimum, and a line break in a path is refused downstream regardless.
A caller value reaching a ddev command is parsed twice while shell_quote_arg escapes for one. docker, docker-compose and vagrant embed the command in a single-quoted remote string. Native has no remote shell. ddev emits bare argv, so the local eval consumes the escaping and ddev joins the argv into `bash -c` inside the container. Escaping cannot close this. Measured against a model of ddev's own quoteArgs, one layer executes a command substitution, two bake literal double quotes into every value, three are a syntax error. No fixed depth is correct, because quoteArgs re-quotes only arguments containing `" \t\r\n#`, so the layer count depends on the value's own content and the sender cannot predict it. That is the same condition the existing single-quote refusal exists for, so assert_no_shell_hostile_chars now refuses shell metacharacters when the environment is ddev. Globs stay allowed, since the container shell expanding them changes which files a tool sees but cannot execute caller text, and spaces stay allowed because ddev double-quotes them and everything dangerous inside double quotes is refused. Reasoned from ddev's source and a stand-in. Not verified against a live ddev project.
docker-compose.sh built `-f ${file_path}` unquoted into the string handed to eval, so a compose file path or project root containing a space split into two arguments.
config_lsp_prefix.bats sourced the plugin copy of config.sh, which is a templated file with two consumers, so shopware-env's copy had only the byte-identity check behind it. It moves to plugin-tests/mcp-shared/config.bats and sources the template directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_jest_scope_env_prefix rendered `KEY=value` pairs joined by spaces, unquoted, ahead of the command handed to eval, so a value containing a space became its own command. `NODE_OPTIONS=--require ./bootstrap.js` executed ./bootstrap.js. Each value now passes through shell_quote_arg and a key or value carrying a line break is refused, which changes emitted output from `KEY=value` to `KEY="value"`. Both server copies were edited identically. scope_js_tools.bats asserted the old unquoted form, so its expectation moves to the quoted one. That assertion described the defect. scope_resolution.bats had two payloads pinning the jq-filter injection fix, but only one of them pins it. The `plugin-"x` payload is refused on the old code too, because a bare double quote made the old interpolated filter a jq syntax error that _scope_jq's `2>/dev/null || echo ""` collapsed into the same not-declared outcome. It is relabelled as input hygiene, and the payload that does reproduce the defect on the old code is labelled as the revert-check, since the pair only reads correctly together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An `ids` value that splits to no usable id reached `"${target_ids[@]}"` empty, and an empty array expansion under set -u aborts before bash 4.4, so instead of the no-valid-IDs refusal that 4.4 and later return, older interpreters died with an unbound-variable error. The expansion now uses the `${arr[@]+…}` guard the sibling scripts already use. Verified only that 3.2 aborts without it and 5.3 behaves correctly with it. No 4.0 to 4.3 interpreter was available.
This server needs bash 4.4, not the 4.0 the other MCP servers need. `declare -gA` in lib/common.sh has required 4.2 since it was introduced, and the `local -` that restores the caller's globbing setting requires 4.4. Both boundaries were confirmed against the bash maintainer's NEWS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No lifecycle tool passes a paths array, so the parse_paths_json line-break refusal does not change this plugin's behavior, but the ddev metacharacter refusal applies to its tool arguments and is a behavior change for users on that environment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
discover-components.sh aborts on a malformed .mcp.json under set -euo pipefail, but every consumer read it as `done < <(discover_mcp_servers)`, and process substitution hides the producer's status from both `$?` and PIPESTATUS. The consumer carried on with whatever the producer managed to emit before dying, which is nothing, so validate-issue-templates.sh compared its dropdown against an empty server list and could report it up-to-date, and update-issue-templates.sh could rewrite that dropdown empty. Fault-injected against a fixture tree holding one malformed file. The old shape continued with exit 0 and zero servers, losing the one good server it had already read. The new shape aborts with exit 2 and names the file.
The per-file jq status is now read explicitly, since errexit is off inside a function whose caller captures its status, and `.mcpServers // {}` keeps a file that legitimately declares no servers from being treated as unreadable. All fourteen consumer sites capture the output and check the status before using it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
plugin-tests/AGENTS.md prefixed only the first of two relocated suites, so environment.bats still read as living under dev-tooling/, and its integration table had no row for plugin-tests/mcp-shared/ though the table exists to map suites to what they cover. plugin-tests/README.md prescribed `--filter-tags blocking plugin-tests/` without `-r`, which selects zero tests and prints 1..0, a result that reads as a pass. Its tag section listed four of the roughly fifty tags in use and never documented file_tags at all. It now separates file_tags from test_tags, gives the full test_tags vocabulary, and points at an enumeration command for file_tags rather than a hand-listed table that drifts on the next commit. The test template gained bats_require_minimum_version, which all thirty-eight suites on disk declare, and a second skeleton for mcp-shared suites, which load by absolute path and source the template rather than a plugin copy. The dev-tooling subtree listing covered ten of nineteen files. plugins/dev-tooling/AGENTS.md listed the relocated suites above a run command that covered none of them. The listing now separates the plugin's own suites from the shared ones and the command covers both directories. The extension files claimed bash 4+ for every MCP server. The real floors are 3.2 for .github/scripts, 4.0 for the shared modules and the dev-tooling and shopware-env servers, and 4.4 for test-writing's. They also asserted that every tracked AGENTS.md opens with `@README.md`, which holds for ten of thirteen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Agent Skills ExportBuilt 7 skill packages: |
Martin Bens (SpiGAndromeda)
deleted the
fix/shell-hardening-and-bash-conventions
branch
August 27, 2026 22:39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every MCP tool function takes a JSON arguments object from an untrusted caller, interpolates values into a command string, and runs that string through
eval. Three of those values reached it with neither quoting nor a refusal.docker_serviceon theshopware-envlifecycle server was embedded bare, so a service name ofweb; idproduceddocker exec -i web; id bash -c '…'and ran as two commands.scopeon thedev-toolingservers was interpolated into a jq filter program rather than passed as data, so a name of" // {"cwd":"$(printf PWNED)"} // .scopes."missingescaped the filter's string literal, was accepted where an ordinary undeclared name is refused, and left the command substitution sitting inSCOPE_CWDforwrap_commandto place inside local double quotes. Separately,validate_tool_argumentsreturned success for anyargumentsvalue that was not a JSON object, sorequired,additionalPropertiesandenumwere skipped on every tool of all four servers, includingdatabase_reset, whose run reachessystem:install --drop-database. The branch began as a convention pass over the repository's bash and BATS against thesoftware-writerskills and became the defects that pass surfaced.dev-toolinggoes 3.17.1 to 3.17.2,shopware-env1.2.4 to 1.2.5, andtest-writing4.2.4 to 4.2.5, the last two carrying the synced copies of the shared files.Caller values that reach a command string
Every value that lands at the local level is now quoted by
shell_quote_argat construction, and a value that no quoting can carry is refused byassert_no_shell_hostile_charsbefore use. The division matters. The guard rejects only a single quote and a line break, because the docker, docker-compose and vagrant wrappers embed the whole command inside a single-quoted remote string, and those two characters are the ones no fixed escaping depth survives. Everything else is made safe by quoting rather than by rejection, which is why adding the guard alone leftweb; idworking.docker_service,compose_fileresolve_lifecycle_envdocker exec -isites acrossenvironment.shanddocker-compose.sh, two of them the npm-path twinsplugin_name,plugin_namespacetool_plugin_setup,tool_plugin_createbin/console plugin:createandplugin:install, hand-wrapped in single quotesscopedev-toolingtool taking itscope.sh, plus theinstall_if_missinglookup in both JS servers throughSCOPE_NAMEjest.envvalueKEY=valuepairs joined by spaces ahead of the command, soNODE_OPTIONS=--require ./bootstrap.jsexecuted./bootstrap.jscompose_file-f ${file_path}unquoted, so a path containing a space splittool_plugin_setupalso validated nothing beyond non-emptiness. The PascalCase pattern its sibling applies lived insidetool_plugin_createalone.Schema validation was skippable by sending a non-object
validate_tool_argumentsended itsjqpipeline with|| true. Whenargumentswas a string, an array ornull,$args | keyserrored, the failure was masked, the message stayed empty, and the function returned success. Measured against a schema declaringrequired: ["force"]andadditionalProperties: false:{"force":true}accepted,{}rejected, and"oops",[1,2]andnullall accepted. A non-object is now rejected by name and type, and ajqfailure is a rejection rather than a skip, on the grounds that a validator which cannot evaluate its input has not validated it.handle_tools_callalso derived the object with.arguments // {}, and//treats a presentnulland a presentfalseas absent, so both bypassed the new rejection until it becamehas("arguments").A line break inside one path became two paths
parse_paths_jsondecoded withjq -r '.[]'and read the result back through a line-oriented loop, so a newline inside one element was indistinguishable from the separator between elements, and the hostile-character guard ran after the split and saw two clean fragments. One element ofsrc/app\n.produced"src/app" ".", and.is the whole tree, so oneslint_fix,stylelint_fixandprettier_fixa single-path request wrote across the entire tree and reported success. That is the same outcome 3.17.0 already fixed once, reached by a different route. The top-level newline guard existed in all six PHP tool libs and none of the thirteen JS ones, so the tools whose whole purpose is path-scoping were the unprotected ones. The refusal now happens before the split.ddev parses twice, and quoting cannot close it
shell_quote_argescapes for exactly one parse. The docker, docker-compose and vagrant branches embed the command in a single-quoted remote string, andnativehas no remote shell, so one parse is the right target for four of the five environments.ddevemits bare argv, so the localevalconsumes the escaping and ddev then joins the argv intobash -cinside the container. Measured against a model of ddev's ownquoteArgs, one escaping layer executes a command substitution, two bake literal double quotes into every value, and three are a syntax error. No fixed depth is correct, becausequoteArgsre-quotes only arguments containing" \t\r\n#, so the layer count depends on the value's own content and the sender cannot predict it. That is the condition the single-quote refusal already exists for, so the guard now refuses shell metacharacters when the environment isddev. Globs stay allowed, since the container shell expanding them changes which files a tool sees but cannot execute caller text. This is a behavior change for ddev users, and it is reasoned from ddev's source rather than run against a live ddev project, which no machine here has.A failed step reported success
tool_install_dependenciesappended eachexec_commandresult to its output, checked no status, and printed the accumulated text last, so a failed composer or npm install returned 0 with the error embedded in what read as a normal result, and the later steps ran anyway.tool_plugin_create's three steps andtool_plugin_setup's two behaved the same way, so a failedplugin:createstill reachedplugin:refreshandplugin:install. Nothing was going to catch these implicitly.mcpserver_core.shdispatches every tool function on the left of||, which disables errexit for the entire body and stops the ERR trap firing, so a status check written ascmd; rc=$?is dead code there. The same shape hid anawkfailure inphpunit_coverage_gaps, which printedNo files with uncovered lines.and returned 0 for a report it had never parsed, and it hid the eight PHP argument parsers whose|| echo '{...}'fallback turned malformed caller JSON into a run against synthesized defaults..github/scriptsbroke in opposite bash versions((failed++))returns non-zero when the pre-increment value is 0, so underset -eon bash 5, which is what CI runs, the first failing check killedvalidate-issue-templates.shand skipped every remaining check and the summary. An unguarded"${arr[@]}"on an empty array aborts underset -uon bash 3.2, which is macOS/bin/bash, andcommand_issue.yml's dropdown is empty today, so the same script aborted locally before printing anything. Both scripts now reach their summary under 3.2.57 and 5.3.15 with byte-identical output.discover-components.shaborts on a malformed.mcp.json, but every consumer read it asdone < <(discover_mcp_servers), and process substitution hides the producer's status from both$?andPIPESTATUS, so the validator could compare its dropdown against an empty server list and report it up to date. Fault-injected against a fixture tree holding one malformed file, the old shape continued with exit 0 and zero servers, losing the one good server it had already read.What this does not close
npm_script_append_safeaccepts a chain whose final segment is neither the tool nor a run-script, soeslint . && echo doneis reported safe and the appended flags and paths land onechowhile ESLint runs unscoped. The obvious fix, an expected-tool parameter matched against the script body, was implemented and reverted. It refuses the Administration's realbuildscript,export VITE_MODE=production && ts-node -T build.ts, andlint:fix,npm run lint -- --fix. Nothing syntactic separates those from the hazard, and the caller knows only the npm script name, never the binary, so closing it needs a signal the function does not have. AKNOWN GAPblock in the function's doc comment records both counterexamples.Two fixes carry stated limits. The ddev refusal set is reasoned from ddev's source and a stand-in, never run against a live ddev project. The
${target_ids[@]+…}guard that stops an empty id array aborting underset -ubefore bash 4.4 was verified only by 3.2 aborting without it and 5.3 behaving correctly with it, since no 4.0 to 4.3 interpreter was available. Thetest-writingserver's own floor rises to 4.4 in this branch, from the 4.2declare -gAalready required, because the globbing restore useslocal -.Tests
829 BATS tests pass, from 810 when the branch first went green and 819 after an independent review round.
plugin-tests/mcp-shared/gains the four suites whose subject is a template-owned module, moved out ofplugin-tests/dev-tooling/so a shared-module regression is no longer reported against one plugin's copy, andconfig_lsp_prefix.batsfollows them asconfig.batssourcing the template directly, which puts behavioral coverage behindshopware-env's copy for the first time.The regression cases for the glob-expansion, jq-filter injection, path-splitting and discovery-producer defects were each run against the pre-fix code to confirm they fail there. That practice came from a failure rather than from discipline. The first
CONV-*glob test passed against the unfixedget.sh, because bats never chdirs and nothing at the repository root matchesCONV-*, so the glob had nothing to expand to and the case proved nothing. It now creates decoy files and runs with the process cwd among them, which is the shape the defect actually takes, since the server's cwd is the user's project root.