Skip to content

fix: quote or refuse caller values reaching a shell, align bash conventions - #161

Merged
Martin Bens (SpiGAndromeda) merged 14 commits into
mainfrom
fix/shell-hardening-and-bash-conventions
Aug 27, 2026
Merged

Martin Bens (SpiGAndromeda) merged 14 commits into
mainfrom
fix/shell-hardening-and-bash-conventions

Conversation

@SpiGAndromeda

@SpiGAndromeda Martin Bens (SpiGAndromeda) commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

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_service on the shopware-env lifecycle server was embedded bare, so a service name of web; id produced docker exec -i web; id bash -c '…' and ran as two commands. scope on the dev-tooling servers was interpolated into a jq filter program rather than passed as data, so a name of " // {"cwd":"$(printf PWNED)"} // .scopes."missing escaped the filter's string literal, was accepted where an ordinary undeclared name is refused, and left the command substitution sitting in SCOPE_CWD for wrap_command to place inside local double quotes. Separately, validate_tool_arguments returned success for any arguments value that was not a JSON object, so required, additionalProperties and enum were skipped on every tool of all four servers, including database_reset, whose run reaches system:install --drop-database. The branch began as a convention pass over the repository's bash and BATS against the software-writer skills and became the defects that pass surfaced. dev-tooling goes 3.17.1 to 3.17.2, shopware-env 1.2.4 to 1.2.5, and test-writing 4.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_arg at construction, and a value that no quoting can carry is refused by assert_no_shell_hostile_chars before 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 left web; id working.

Value Where it entered Reached
docker_service, compose_file resolve_lifecycle_env four docker exec -i sites across environment.sh and docker-compose.sh, two of them the npm-path twins
plugin_name, plugin_namespace tool_plugin_setup, tool_plugin_create bin/console plugin:create and plugin:install, hand-wrapped in single quotes
scope every dev-tooling tool taking it five interpolated jq filters in scope.sh, plus the install_if_missing lookup in both JS servers through SCOPE_NAME
a jest.env value a scope's config KEY=value pairs joined by spaces ahead of the command, so NODE_OPTIONS=--require ./bootstrap.js executed ./bootstrap.js
compose_file the compose module -f ${file_path} unquoted, so a path containing a space split

tool_plugin_setup also validated nothing beyond non-emptiness. The PascalCase pattern its sibling applies lived inside tool_plugin_create alone.

Schema validation was skippable by sending a non-object

validate_tool_arguments ended its jq pipeline with || true. When arguments was a string, an array or null, $args | keys errored, the failure was masked, the message stayed empty, and the function returned success. Measured against a schema declaring required: ["force"] and additionalProperties: false: {"force":true} accepted, {} rejected, and "oops", [1,2] and null all accepted. A non-object is now rejected by name and type, and a jq failure is a rejection rather than a skip, on the grounds that a validator which cannot evaluate its input has not validated it. handle_tools_call also derived the object with .arguments // {}, and // treats a present null and a present false as absent, so both bypassed the new rejection until it became has("arguments").

A line break inside one path became two paths

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 the hostile-character guard ran after the split and saw two clean 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. 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_arg escapes for exactly one parse. The docker, docker-compose and vagrant branches embed the command in a single-quoted remote string, and native has no remote shell, so one parse is the right target for four of the five environments. ddev emits bare argv, so the local eval consumes the escaping and ddev then joins the argv into bash -c inside the container. Measured against a model of ddev's own quoteArgs, 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, 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 condition the single-quote refusal already exists for, so the guard 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. 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_dependencies appended each exec_command result 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 and tool_plugin_setup's two behaved the same way, so a failed plugin:create still reached plugin:refresh and plugin:install. Nothing was going to catch these implicitly. mcpserver_core.sh dispatches 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 as cmd; rc=$? is dead code there. The same shape hid an awk failure in phpunit_coverage_gaps, which printed No 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/scripts broke in opposite bash versions

((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. An 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 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.sh aborts on a malformed .mcp.json, but every consumer read it as done < <(discover_mcp_servers), and process substitution hides the producer's status from both $? and PIPESTATUS, 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_safe accepts a chain whose final segment is neither the tool nor a run-script, so eslint . && echo done is reported safe and the appended flags and paths land on echo while ESLint runs unscoped. The obvious fix, an expected-tool parameter matched against the script body, was implemented and reverted. It refuses the Administration's real build script, export VITE_MODE=production && ts-node -T build.ts, and lint: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. A KNOWN GAP block 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 under set -u before 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. The test-writing server's own floor rises to 4.4 in this branch, from the 4.2 declare -gA already required, because the globbing restore uses local -.

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 of plugin-tests/dev-tooling/ so a shared-module regression is no longer reported against one plugin's copy, and config_lsp_prefix.bats follows them as config.bats sourcing the template directly, which puts behavioral coverage behind shopware-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 unfixed get.sh, because bats never chdirs and nothing at the repository root matches CONV-*, 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.

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>
@github-actions

Copy link
Copy Markdown

@SpiGAndromeda Martin Bens (SpiGAndromeda) changed the title Fix/shell hardening and bash conventions fix: quote or refuse caller values reaching a shell, align bash conventions Aug 27, 2026
@SpiGAndromeda
Martin Bens (SpiGAndromeda) merged commit d698712 into main Aug 27, 2026
13 checks passed
@SpiGAndromeda
Martin Bens (SpiGAndromeda) deleted the fix/shell-hardening-and-bash-conventions branch August 27, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant