Skip to content

feat(validation): reject shell-string command in MCP stdio entries#809

Merged
danielmeppiel merged 13 commits intomainfrom
feat/mcp-stdio-shell-string-validation
Apr 21, 2026
Merged

feat(validation): reject shell-string command in MCP stdio entries#809
danielmeppiel merged 13 commits intomainfrom
feat/mcp-stdio-shell-string-validation

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

Summary

Reject self-defined stdio MCP entries with whitespace-containing command and no args at parse time. Surfaces with a fix-it error pointing at the canonical shape.

Why

Surfaced via #122 (thanks @lirantal). Users coming from the universal mcp.json / Claude Desktop / Cursor mental model write:

- name: nodejs-api-docs
  registry: false
  transport: stdio
  command: "npx mcp-server-nodejs-api-docs"   # WRONG: shell line in `command`

and get confused when nothing works. APM never whitespace-split command; per schema, command is a single binary path and args is the list of arguments. Silently accepting the loose shape was a UX trap.

What changes

MCPDependency.validate() now raises ValueError when:

  • registry: false, AND
  • transport == "stdio", AND
  • command is a string containing whitespace, AND
  • args is empty/missing

Error message includes a fix-it that splits the offending command at the first whitespace boundary and shows the corrected command: / args: shape.

Self-defined MCP dependency 'nodejs-api-docs': 'command' must be a single binary
path, not a shell line. APM does not split 'command' on whitespace.
Got: command='npx -y mcp-server-nodejs-api-docs'.
Did you mean: command: npx, args: ["-y", "mcp-server-nodejs-api-docs"] ?
See https://microsoft.github.io/apm/guides/mcp-servers/ for the canonical stdio shape.

Why error, not warn

Per maintainer steer (issue #806 thread): "move fast, breaking changes OK if they make sense, adoption base small enough." Panel ratified:

  • devx-ux: npm/cargo error on bad manifests; warn-then-mis-execute is worse UX.
  • supply-chain-security: Silently passing a whitespace string to a runtime that doesn't split = broken or surprising. Fail closed.
  • CEO: The loose shape was never specified; CHANGELOG (BREAKING) entry covers the comms.

If user feedback shows broader migration friction, downgrading to a warn is a one-line change.

Edge cases covered (tests)

  • Canonical shape (command: npx, args: [...]) keeps working
  • Whitespace command + empty args -> rejected with fix-it
  • Whitespace command + explicit args -> accepted (paths with spaces, e.g. /opt/My App/server, are legal when author has owned shape)
  • Tabs in command -> also rejected (covers \t, not just spaces)
  • Error message includes the suggested command: and args: tokens

Tests

tests/unit/test_mcp_overlays.py: 46 passed
tests/unit tests/test_console.py: 4069 passed

CHANGELOG

Added under ## [Unreleased] / Changed (BREAKING).

Followups

Closes #806
Refs #122

danielmeppiel and others added 10 commits April 20, 2026 13:07
Implements the core decision engine for issue #778 'transport selection v1'.
Strict-by-default semantics replace today's silent cross-protocol fallback:
explicit ssh:// and https:// dependencies no longer downgrade to a different
protocol, and shorthand (owner/repo) consults git insteadOf rewrites before
defaulting to HTTPS.

This commit ships Waves 1+2 of the transport-selection plan (per session plan):
- new module src/apm_cli/deps/transport_selection.py with ProtocolPreference,
  TransportAttempt/TransportPlan dataclasses, GitConfigInsteadOfResolver,
  and TransportSelector that returns a typed, strict-by-default plan
- DependencyReference grows explicit_scheme so the selector can distinguish
  user-stated transport from shorthand
- _clone_with_fallback in github_downloader.py now iterates the selector
  plan; per-attempt URL building stays in the orchestrator
- InstallContext / InstallRequest / pipeline / Service threaded with
  protocol_pref + allow_protocol_fallback so CLI args reach the downloader
- apm install gains --ssh / --https (mutually exclusive) and
  --allow-protocol-fallback flags; honours APM_GIT_PROTOCOL and
  APM_ALLOW_PROTOCOL_FALLBACK env vars
- two pre-existing tests in test_auth_scoping.py asserted the legacy
  permissive chain; updated to assert the new strict contract and added a
  coverage test for the allow_fallback escape hatch

Tests: 4029 unit tests pass. Test matrix + integration tests + docs land
in subsequent commits per Waves 3-5.

Refs #778, #328, #661

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…one env

Wave 2 panel gate (code-review subagent) flagged that
GitHubPackageDownloader._clone_with_fallback decided the clone env
(locked-down vs relaxed) ONCE per dependency from has_token. Under
allow_fallback=True the plan can mix attempts of different use_token
values, so SSH and plain-HTTPS attempts in a mixed chain were running
with GIT_ASKPASS=echo + GIT_CONFIG_GLOBAL=/dev/null +
GIT_CONFIG_NOSYSTEM=1, breaking ssh-agent passphrase prompts and git
credential helpers.

Fix the env per attempt; address an adjacent contract bug; add tests.

* github_downloader._clone_with_fallback: replace the per-dep clone_env
  with a per-attempt _env_for() helper so only token-bearing attempts
  get the locked-down env.
* github_downloader._build_repo_url: treat token="" as an explicit "no
  token" sentinel so plain-HTTPS attempts in a mixed chain genuinely
  run without embedded credentials, letting credential helpers (gh
  auth, Keychain) supply auth. Orchestrator passes "" instead of None
  for use_token=False attempts.
* transport_selection.GitConfigInsteadOfResolver: wrap the lazy
  insteadOf-rewrite cache in a threading.Lock so parallel downloads
  can't double-populate.

Tests:
* tests/unit/test_transport_selection.py (NEW, 30 tests): 14-row
  selection matrix (explicit-strict, shorthand+insteadOf, shorthand
  defaults, CLI prefs, allow_fallback chain, env helpers); resolver
  caching; "must use normal env" contract.
* tests/unit/test_auth_scoping.py: new
  test_allow_fallback_env_is_per_attempt_not_per_dep regression
  asserts auth-HTTPS gets locked-down env, SSH and plain-HTTPS get
  relaxed env, and plain-HTTPS does not embed the token in the URL.
* tests/integration/test_transport_selection_integration.py (NEW, 7
  tests): 2 always-on cases (public shorthand HTTPS; explicit https://
  strict); 5 SSH-required cases (explicit ssh:// strict, bad-host
  no-fallback, insteadOf override, APM_GIT_PROTOCOL=ssh env,
  allow_fallback rescue). Gated on APM_RUN_INTEGRATION_TESTS=1; SSH
  cases auto-skip if no key.
* tests/fixtures/gitconfig_insteadof_to_ssh (NEW): minimal gitconfig
  used by the integration test for the insteadOf-honored case.
* scripts/test-integration.sh: added "Transport Selection" block so
  the integration suite runs in CI.

Full unit suite: 4061 passed (was 4029; +32 net new tests).

Refs #778, #661, #328

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update the four user-facing surfaces affected by the new TransportSelector
contract:

* docs/src/content/docs/guides/dependencies.md:
  - New "Transport selection (SSH vs HTTPS)" section: breaking-change
    callout with the rescue env var, selection matrix, insteadOf
    example, --ssh / --https / APM_GIT_PROTOCOL overrides, and the
    --allow-protocol-fallback escape hatch.
  - Soften the existing "Custom ports preserved" sentence (cross-protocol
    retries are now opt-in).
  - Update the "Other Git Hosts" SSH bullet: SSH is no longer a silent
    fallback; point at explicit URLs or insteadOf.
* docs/src/content/docs/getting-started/authentication.md:
  - Rewrite the "SSH connection hangs" troubleshooting entry: remove the
    now-incorrect "tries SSH then falls back to HTTPS" framing.
  - New "Choosing transport (SSH vs HTTPS)" section with a pointer to
    dependencies.md for the full transport contract.
* docs/src/content/docs/reference/cli-commands.md:
  - Document --ssh / --https / --allow-protocol-fallback on apm install,
    plus APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars.
* packages/apm-guide/.apm/skills/apm-usage/dependencies.md (Rule 4 mirror):
  - Same transport contract in skill-resource voice with three runnable
    snippets and a selection matrix.

CHANGELOG: scope new entries to `apm install` only (there is no `apm
add` command in the codebase).

Refs #778

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two findings from the Copilot reviewer on PR #779:

1. Non-ASCII em dash introduced by this PR in the modified `Fields:`
   line of guides/dependencies.md: replace with `--`. The other
   non-ASCII chars Copilot flagged in the file (lines ~150-160 of the
   "nested groups" warning block) are pre-existing and out of scope
   for this PR.
2. CHANGELOG entries for the new transport-selection feature were too
   long and bundled multiple concerns into one bullet. Split into one
   tighter BREAKING entry plus two single-purpose Added entries
   (initial-protocol flags; fallback escape hatch). Each ends in
   `(#778)`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- docs(dependencies): pin BREAKING-change caution panel to APM 0.8.13
  (per maintainer review comment)
- transport_selection: collapse explicit `http://` URLs into the plain-HTTPS
  branch so TransportAttempt.scheme stays in {ssh, https} and the downloader
  contract is consistent until #700 lands a first-class HTTP transport
- github_downloader: gate the [!] "Protocol fallback" warning on actual
  scheme change (ssh<->https) rather than label change, so an auth downgrade
  inside a single protocol is not misreported as a protocol switch
- pipeline / request / context / commands: switch
  `allow_protocol_fallback` to `Optional[bool] = None` end-to-end so
  programmatic callers (non-CLI) keep the documented "None => read
  APM_ALLOW_PROTOCOL_FALLBACK env" behavior
- test_auth_scoping: ASCII-only docstring (replace one stray Unicode arrow)

All 4061 unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ade (#780)

obra/superpowers (and any Claude Code plugin shipping both hooks/*.json
and agents/skills/commands) was misclassified as HOOK_PACKAGE because
the cascade in detect_package_type() checked _has_hook_json BEFORE the
plugin-evidence branch. The plugin synthesizer (_map_plugin_artifacts)
already maps hooks alongside agents/skills/commands, so MARKETPLACE_PLUGIN
is a strict superset -- swapping the order means hooks still install,
plus everything else that was being silently dropped.

Three deliverables:

A) Surgical detection fix: reorder cascade so MARKETPLACE_PLUGIN is
   checked before HOOK_PACKAGE. Refactored to use a new
   gather_detection_evidence() helper + DetectionEvidence dataclass so
   observability code (warnings, summaries) can reuse the same scan
   without breaking the detect_package_type() public signature.

B) Observability:
   - Add HOOK_PACKAGE to the package-type label table (it was missing
     entirely -- the silent classification path).
   - Update MARKETPLACE_PLUGIN label to mention plugin.json OR
     agents/skills/commands (matches the cascade behaviour).
   - New CommandLogger.package_type_warn() at default visibility.
   - New _warn_if_classification_near_miss() helper fires when a
     HOOK_PACKAGE classification disagrees with directory contents
     (catches near-misses the order swap cannot, e.g. .claude-plugin/
     dir without plugin.json).
   - Wired at both materialization sites (local + cached).

C) Architectural follow-up tracked in plan; will file as separate
   issue after merge for a Visitor / Format-Discovery refactor.

Tests: 17 detection tests + 9 sources-observability tests + 70
command-logger tests pass. Full unit suite (4180 tests) green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…in/ evidence

Per Copilot review on PR #781: the near-miss warning helper had dead
code paths once the cascade was reordered (a HOOK_PACKAGE classification
implies plugin_dirs_present is empty -- otherwise it would be
MARKETPLACE_PLUGIN). Two principled options were offered: simplify the
helper, or broaden detection so the invariants stay consistent. Chose
the latter -- it removes the inconsistency at the source.

Changes:
- Add .claude-plugin/ as first-class plugin evidence in DetectionEvidence
  (new has_claude_plugin_dir field) and in has_plugin_evidence. A Claude
  Code plugin without a plugin.json (manifest-less) now classifies as
  MARKETPLACE_PLUGIN; normalize_plugin_directory already handles the
  missing-manifest case (derives name from directory).
- Drop _warn_if_classification_near_miss helper, its two call sites,
  CommandLogger.package_type_warn method, and the corresponding tests.
  All scenarios it covered are now handled by the cascade itself.
- Add regression test test_claude_plugin_dir_alone_is_plugin_evidence
  asserting that .claude-plugin/ + hooks/ classifies as MARKETPLACE_PLUGIN
  with plugin_json_path=None (matched via directory evidence alone).
- Extend test_obra_superpowers_evidence to assert has_claude_plugin_dir.

Verified: 4175 unit tests pass (excluding the one pre-existing unrelated
failure documented on main).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Self-defined stdio MCP entries with `command` containing whitespace and
no `args` are now rejected at parse time with a fix-it error pointing at
the canonical `command: <binary>, args: [<token>, ...]` shape.

Previously silently accepted; APM never split `command` on whitespace,
so the loose shape mis-executed downstream. The trap surfaced via #122
(thanks @lirantal) -- users coming from the universal `mcp.json` /
Claude Desktop / Cursor mental model wrote `command: "npx mcp-server-foo"`
and got confused when nothing worked.

Per maintainer steer ("move fast, breaking OK"): error in v1, not warn.
The loose shape was never specified; silent mis-execution is worse than
hard-fail with a clear fix-it. CHANGELOG entry under
Changed (BREAKING).

Closes #806
Refs #122

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 21, 2026 08:31
@danielmeppiel danielmeppiel added enhancement Deprecated: use type/feature. Kept for issue history; will be removed in milestone 0.10.0. breaking-change dx cli Deprecated: use area/cli. Kept for issue history; will be removed in milestone 0.10.0. labels Apr 21, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds stricter parse-time validation for self-defined stdio MCP dependencies to prevent the common “shell string in command” misconfiguration, and also updates package-type detection/observability to address plugin-vs-hooks classification edge cases.

Changes:

  • Reject self-defined stdio MCP entries where command contains whitespace and args is missing/empty, with a fix-it suggestion in the error.
  • Introduce richer package-type detection evidence and reorder the detection cascade to classify plugin-shaped packages before hook-only packages.
  • Add/extend unit tests and update CHANGELOG.md entries for the behavior changes.
Show a summary per file
File Description
src/apm_cli/models/dependency/mcp.py Adds the new stdio shell-string command validation and fix-it error text.
tests/unit/test_mcp_overlays.py Adds unit tests covering the new MCP validation behavior and error contents.
src/apm_cli/models/validation.py Adds DetectionEvidence + gather_detection_evidence() and changes detection order to prefer plugin evidence over hooks.
tests/test_apm_package_models.py Adds regression tests for plugin-vs-hooks classification and new evidence-gathering helper.
src/apm_cli/install/sources.py Centralizes package-type label formatting and ensures hook packages get a label.
tests/unit/install/test_sources_classification.py New tests ensuring every classifiable PackageType has a human-readable label.
CHANGELOG.md Adds entries describing the new MCP validation breaking change and the plugin classification fix.

Copilot's findings

Comments suppressed due to low confidence (1)

src/apm_cli/models/dependency/mcp.py:142

  • This validation rejects any whitespace in command when args is empty, but per the schema command is a binary path and valid paths can contain spaces (notably on Windows, e.g. under Program Files). Consider narrowing the check to the specific "shell line" anti-pattern (e.g. multiple tokens and command doesn't look like a path) so you don't block legitimate single-binary paths that contain spaces but have no arguments.
            if (
                self.transport == 'stdio'
                and isinstance(self.command, str)
                and any(ch.isspace() for ch in self.command)
                and not self.args
            ):
  • Files reviewed: 7/7 changed files
  • Comments generated: 7

Comment thread CHANGELOG.md Outdated
### Changed (BREAKING)

- Strict-by-default transport selection: explicit `ssh://`/`https://` URLs no longer silently fall back to the other protocol; shorthand consults `git config url.<base>.insteadOf` and otherwise defaults to HTTPS. Set `APM_ALLOW_PROTOCOL_FALLBACK=1` (or pass `--allow-protocol-fallback`) to restore the legacy permissive chain; cross-protocol retries then emit a `[!]` warning. Closes #328 (#778)
- Self-defined stdio MCP entries with `command` containing whitespace and no `args` are now rejected at parse time with a fix-it error pointing at the canonical `command: <binary>, args: [<token>, ...]` shape. Previously silently accepted; APM never split `command` on whitespace, so the loose shape mis-executed downstream. Closes #806 (#122)
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changelog entry ends with (#122), but #122 is an issue (already referenced via Closes #806/Refs #122) and cannot also be the PR number. Per the repo changelog convention, the trailing parenthetical should be the PR number for this change.

Copilot generated this review using guidance from repository custom instructions.
Comment thread CHANGELOG.md

### Fixed

- `apm install` no longer silently drops skills, agents, and commands when a Claude Code plugin also ships `hooks/*.json`. The package-type detection cascade now classifies plugin-shaped packages as `MARKETPLACE_PLUGIN` (which already maps hooks via the plugin synthesizer) before falling back to the hook-only classification, and emits a default-visibility `[!]` warning when a hook-only classification disagrees with the package's directory contents (#780)
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entry appears to be a normal code change but does not end with a PR number, and (#780) reads like an issue reference rather than the required (#PR_NUMBER) suffix. Please adjust to Keep-a-Changelog format: one line per PR, ending with this PR's number (issue refs can stay in the sentence body).

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +235 to +245
evidence = gather_detection_evidence(package_path)

if has_apm_yml and has_skill_md:
if evidence.has_apm_yml and evidence.has_skill_md:
return PackageType.HYBRID, None
if has_apm_yml:
if evidence.has_apm_yml:
return PackageType.APM_PACKAGE, None
if has_skill_md:
if evidence.has_skill_md:
return PackageType.CLAUDE_SKILL, None
if _has_hook_json(package_path):
if evidence.has_plugin_evidence:
return PackageType.MARKETPLACE_PLUGIN, evidence.plugin_json_path
if evidence.has_hook_json:
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR's title/description are focused on MCP stdio validation (#806/#122), but this diff also adds new package-type detection behavior (gather_detection_evidence, .claude-plugin evidence, and a reordered detection cascade) tied to #780. If this extra scope is intentional, it should be called out explicitly in the PR description; otherwise consider splitting to keep review/rollback risk isolated.

Copilot uses AI. Check for mistakes.
Comment thread src/apm_cli/models/dependency/mcp.py Outdated
and any(ch.isspace() for ch in self.command)
and not self.args
):
first, _, rest = self.command.strip().partition(' ')
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix-it suggestion splits command using .partition(' '), which only handles a literal space. For inputs containing other whitespace (e.g. tabs, multiple whitespace), the validation still triggers but the suggested command/args shape will be incorrect (e.g. npx\t-y\tpkg produces command: npx\t-y\tpkg, args: []). Split on any whitespace with a single pass (e.g. split(maxsplit=1) / regex \s+) so the suggestion matches the validation condition (isspace()).

This issue also appears on line 137 of the same file.

Suggested change
first, _, rest = self.command.strip().partition(' ')
command_parts = self.command.strip().split(maxsplit=1)
first = command_parts[0] if command_parts else ''
rest = command_parts[1] if len(command_parts) > 1 else ''

Copilot uses AI. Check for mistakes.
Comment thread src/apm_cli/models/dependency/mcp.py Outdated
f"APM does not split 'command' on whitespace. "
f"Got: command={self.command!r}. "
f"Did you mean: command: {first}, args: {suggested_args} ? "
f"See https://microsoft.github.io/apm/guides/mcp-servers/ "
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message links to https://microsoft.github.io/apm/guides/mcp-servers/, but there is no mcp-servers guide in the current docs tree, so users will hit a 404 from the fix-it error. Either add the page in this PR or point to an existing canonical section (e.g. the current MCP dependency docs) until the new guide lands.

Suggested change
f"See https://microsoft.github.io/apm/guides/mcp-servers/ "
f"See https://microsoft.github.io/apm/reference/dependencies/ "

Copilot uses AI. Check for mistakes.
Comment on lines +1149 to +1162
def test_empty_directory(self, tmp_path):
from apm_cli.models.validation import gather_detection_evidence

evidence = gather_detection_evidence(tmp_path)
assert evidence.has_apm_yml is False
assert evidence.has_skill_md is False
assert evidence.has_hook_json is False
assert evidence.plugin_json_path is None
assert evidence.plugin_dirs_present == ()
assert evidence.has_plugin_evidence is False

def test_records_plugin_dirs_in_canonical_order(self, tmp_path):
from apm_cli.models.validation import gather_detection_evidence

Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests mix src.apm_cli... and apm_cli... imports for the same module. That can import the same source file twice under different module names, leading to duplicated class/enum singletons (e.g. PackageType identity mismatches) and flaky isinstance / equality behavior. Please standardize on a single import style within this file (preferably the existing src.apm_cli... used at the top).

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +61
PackageType.CLAUDE_SKILL: "Skill (SKILL.md detected)",
PackageType.MARKETPLACE_PLUGIN:
"Marketplace Plugin (plugin.json or agents/skills/commands)",
PackageType.HYBRID: "Hybrid (apm.yml + SKILL.md)",
PackageType.APM_PACKAGE: "APM Package (apm.yml)",
PackageType.HOOK_PACKAGE: "Hook Package (hooks/*.json only)",
}.get(pkg_type)
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MARKETPLACE_PLUGIN classification now also triggers on a bare .claude-plugin/ directory (see detect_package_type), but this label only mentions plugin.json or agents/skills/commands. That makes the install-time classification log misleading for the manifest-less plugin case. Update the wording (or make it more generic) so it matches the actual detection signals.

Copilot uses AI. Check for mistakes.
- mcp.py: fix-it suggestion now uses split(maxsplit=1) so tab- and
  multi-space-separated commands produce a correct canonical shape
  (.partition(' ') only handled U+0020, leaving tabs in the suggested
  binary path -- itself invalid). Adds regression test.
- CHANGELOG: trailing parenthetical now carries this PR's number (#809);
  the original (#122) was an issue reference. Issue stays cited inline.
- Resolve CHANGELOG merge conflict against main: keep VS Code adapter
  http-default Fixed entry (#654).

Out-of-scope plugin/hook reclassification work (Copilot review #3) is
already on main via PR #781 and falls out of this branch's net diff
post-merge; no action needed.

Doc link in the validator (guides/mcp-servers/) is now valid -- the
guide landed via PR #810. No change required.

Refs #122, closes #806

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel
Copy link
Copy Markdown
Collaborator Author

Review: Approve with required changes

Strong change. Closes a real footgun (#806, refs #122) with the right call (error, not warn) and good test coverage. Two items must be addressed before merge; three should be addressed in this PR.

This synthesis comes from the APM Review Panel (Python Architect, CLI Logging Expert, DevX UX Expert, Supply-Chain Security Expert, OSS Growth Hacker), arbitrated by the CEO persona.

Must fix before merge

M1. Credential leak in error message (mcp.py line 225)

f"Got: command={self.command!r}" echoes the raw command verbatim. If a user writes command: "npx --token=SECRET mcp-server", the token appears in CLI stderr and CI scrollback. The class already redacts env and headers in __repr__ -- same caution must apply to error messages.

Fix: use the already-computed first variable:

f"Got: command={first!r} ({len(rest_tokens)} additional args). "

M2. not self.args rejects explicit args: [] (mcp.py line 212)

args: [] is a valid "no extra arguments" signal. not [] evaluates True, so the whitespace check fires incorrectly. This rejects valid input in a BREAKING change -- we cannot ship that.

Fix: change and not self.args to and self.args is None. Add a regression test asserting command: "/opt/My App/server" with args: [] validates cleanly.

Should fix in this PR

S1. Whitespace-only command produces broken fix-it (mcp.py lines 217-218)

command: " " produces Did you mean: command: , args: [] (nonsensical). After command_parts = self.command.strip().split(maxsplit=1), guard the empty case with a dedicated "command is empty/whitespace-only" error.

S2. Type gate for non-str command (mcp.py ~line 170)

YAML command: ["npx", "-y", "evil"] (list) bypasses the isinstance(self.command, str) guard silently, then crashes in validate_path_segments with an unhandled AttributeError. Add early in the universal hardening section:

if self.command is not None and not isinstance(self.command, str):
    raise ValueError(
        f"MCP dependency '{self.name}': 'command' must be a string, "
        f"got {type(self.command).__name__}"
    )

S3. Update manifest schema spec (docs/src/content/docs/reference/manifest-schema.md section 4.2.3)

Add rule 4 to the validation rules:

  1. If transport is stdio, command MUST be a single binary path with no embedded whitespace. Use args for additional arguments.

Spec and code must ship together -- project convention.

Follow-up tickets (file after merge, do not block)

  • fix(mcp): redact command in MCPDependency.__repr__ (credential leak surface) -- pre-existing in __repr__ line 121.
  • fix(plugins): forward MCPDependency validation errors to DiagnosticCollector -- plugin-parser path uses stdlib logging.warning, invisible without --verbose.
  • improve(mcp): use multi-line Cargo-style error format for validation messages -- the 350-char single-line ValueError defeats terminal URL detection.
  • feat(mcp): warn on shell metacharacters in stdio command (defense-in-depth) -- command: "npx|curl..." (no whitespace) passes validation. No real risk (execve-style, no shell=True), but a warning would help.

Declined / out of scope

  • Triple-nested error prefix in consumer re-raise (pre-existing wrapping pattern).
  • Exit code 2 vs 1 divergence between UsageError and ValueError (pre-existing, broader concern).
  • NBSP "shell line" wording (pathological input; wording is accurate enough).

Closes the #122 footgun with the right severity (error, not warn), good test matrix, and a clear CHANGELOG entry. The fix-it suggestion is genuinely helpful DX -- the kind of detail that earns trust. Address M1+M2+S1-S3 and this ships.

danielmeppiel and others added 2 commits April 21, 2026 19:02
…, edge cases)

Addresses the APM Review Panel verdict on PR #809.

Must-fix:
- M1: Redact raw command in error 'Got:' framing (cli-log BLOCKER /
  sec HIGH). Echoing 'Got: command={self.command!r}' leaked tokens
  like '--token=ghp_...' to stderr / CI scrollback. Now shows only
  the first token plus argument count. The structured 'Did you mean:'
  suggestion still surfaces user input verbatim because that is the
  copy-paste recovery path.
- M2: Use 'self.args is None' instead of 'not self.args' (arch
  IMPORTANT). Explicit 'args: []' is a deliberate 'no extra args'
  signal (e.g., paired with '/opt/My App/server') and must be
  accepted -- 'not []' incorrectly evaluated truthy and rejected
  legitimate input in a BREAKING change.

Should-fix:
- S1: Whitespace-only command produces a dedicated 'empty or
  whitespace-only' error instead of the degenerate fix-it
  'Did you mean: command: , args: []' (arch + devx IMPORTANT).
- S2: Type gate for non-str command (sec HIGH). YAML
  'command: ["npx", "-y", "x"]' previously bypassed the
  isinstance guard silently and crashed downstream in
  validate_path_segments with an unhandled AttributeError.
- S3: Document rule 4 in manifest-schema.md section 4.2.3 (devx
  IMPORTANT). Spec and code ship together.

Adds 4 regression tests covering each fix. Removes the stray space
before '?' in the fix-it suggestion (cli-log NIT 8).

Follow-ups (not in this PR, to be filed as issues):
- Redact 'command' in MCPDependency.__repr__ (sec MEDIUM, pre-existing)
- Forward MCPDependency validation errors from plugin parser to
  DiagnosticCollector (cli-log IMPORTANT)
- Multi-line Cargo-style error format (cli-log IMPORTANT / devx NIT)
- Shell-metachar warning for stdio command (sec LOW)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per user direction, fold all four CEO-classified follow-up items into
this PR rather than deferring to separate issues.

FU1 -- Redact 'command' in MCPDependency.__repr__ (sec MEDIUM)
  Pre-existing leak: __repr__ echoed 'command={self.command!r}' verbatim
  while carefully redacting env and headers. Now shows only the first
  whitespace-separated token, mirroring the M1 fix.

FU2 -- Surface plugin-parser MCP validation warnings (cli-log IMPORTANT)
  The 'apm' stdlib logger has no handlers configured, so logger.warning
  calls in plugin_parser were silently dropped. Added _surface_warning
  helper that routes through both stdlib logger AND _rich_warning so
  invalid MCP servers are visible without --verbose. Applied to the
  validation-error catch site and the no-command/no-url skip.

FU3 -- Multi-line Cargo-style error format (cli-log IMPORTANT / devx NIT)
  The original 350-char single-line ValueError defeated terminal URL
  detection and the newspaper test. Restructured to:
    'command' contains whitespace in MCP dependency '<name>'.
      Rule: ...
      Got:  command='<first>' (N additional args)
      Fix:  command: <first>
            args: [...]
      See:  https://...
  URL now sits on its own line for click-through; field/rule/got/fix/see
  pattern is scannable per the cli-logging-ux skill's newspaper test.

FU4 -- Shell-metachar warning for stdio command (sec LOW, defense-in-depth)
  Extended _warn_shell_metachars(env, logger) to optionally check
  'command' as well, so 'command: "npx|curl evil.com"' (no whitespace,
  passes the rejection guard) still triggers a warning that MCP stdio
  servers run via execve with no shell. Hooked into the --mcp install
  path via entry.get('command').

Architectural improvement (LOC budget):
  Adding the command-checking branch pushed install.py over the 1525
  invariant ceiling. Per the python-architecture skill's guidance
  ('don't trim cosmetically -- modularize'), extracted the F5 SSRF
  helper, F7 shell-metachar helper, _is_internal_or_metadata_host,
  _SHELL_METACHAR_TOKENS, and _METADATA_HOSTS into a new dedicated
  module: apm_cli/install/mcp_warnings.py. install.py back-binds the
  symbols at module scope so existing test patches against
  apm_cli.commands.install._warn_* keep working unchanged.

  install.py: 1530 -> 1441 LOC (84 under budget, room to breathe).

Tests: 4715/4715 unit + console pass (excludes the known pre-existing
test_user_scope_skips_workspace_runtimes failure on main).

New regression tests:
- test_validate_stdio_error_uses_multiline_cargo_style_format
- test_repr_redacts_command_to_avoid_leaking_credentials

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel
Copy link
Copy Markdown
Collaborator Author

Follow-up tickets folded into this PR (commit 50858ee)

Per maintainer direction, all four CEO-classified follow-up items landed in this PR rather than being deferred to separate issues.

FU1 — MCPDependency.__repr__ credential leak (sec MEDIUM, pre-existing)

__repr__ echoed command={self.command!r} verbatim while carefully redacting env and headers. Now shows only the first whitespace-separated token, mirroring the M1 fix. Regression test: test_repr_redacts_command_to_avoid_leaking_credentials.

FU2 — Surface plugin-parser MCP validation warnings (cli-log IMPORTANT)

The apm stdlib logger has no handlers configured, so logger.warning calls in plugin_parser.py were silently dropped. Added _surface_warning helper that routes through both stdlib logger AND _rich_warning so invalid MCP servers from a plugin are visible without --verbose. Applied to both the validation-error catch site and the no-command/no-url skip.

FU3 — Multi-line Cargo-style error format (cli-log IMPORTANT / devx NIT)

The original 350-char single-line ValueError defeated terminal URL detection and the newspaper test. Restructured to the field/rule/got/fix/see pattern:

'command' contains whitespace in MCP dependency '<name>'.
  Rule: 'command' must be a single binary path -- APM does not split on whitespace. Use 'args' for additional arguments.
  Got:  command='npx' (2 additional args)
  Fix:  command: npx
        args: ["-y", "pkg"]
  See:  https://microsoft.github.io/apm/guides/mcp-servers/

URL now sits on its own line for click-through. Regression test: test_validate_stdio_error_uses_multiline_cargo_style_format.

FU4 — Shell-metachar warning for stdio command (sec LOW, defense-in-depth)

Extended _warn_shell_metachars(env, logger) to optionally check command as well, so command: "npx|curl evil.com" (no whitespace, passes the rejection guard) still triggers a warning that MCP stdio servers run via execve with no shell. Hooked into the --mcp install path via entry.get("command").

Architecture (python-architecture skill)

Adding the command-checking branch pushed commands/install.py over the 1525 LOC architectural invariant. Per the skill's guidance ("don't trim cosmetically — modularize"), extracted the F5 SSRF helper, F7 shell-metachar helper, _is_internal_or_metadata_host, _SHELL_METACHAR_TOKENS, and _METADATA_HOSTS into a new dedicated module: src/apm_cli/install/mcp_warnings.py. install.py back-binds the symbols at module scope so existing test patches against apm_cli.commands.install._warn_* keep working unchanged.

install.py: 1530 → 1441 LOC (84 under budget, room to breathe).

Verification

  • 4715/4715 unit + console tests pass (excludes the known pre-existing test_user_scope_skips_workspace_runtimes failure on main, unrelated to this PR).
  • Net PR diff vs main now: mcp.py +47, mcp_warnings.py +122 (new), plugin_parser.py +30, test_mcp_overlays.py +151, manifest-schema.md +1, install.py net unchanged (extracted code = imported back).

@danielmeppiel danielmeppiel merged commit e1ea5a7 into main Apr 21, 2026
11 checks passed
@danielmeppiel danielmeppiel deleted the feat/mcp-stdio-shell-string-validation branch April 21, 2026 18:22
danielmeppiel added a commit to arika0093/apm that referenced this pull request Apr 21, 2026
The combined surface of PR microsoft#700 (allow-insecure CLI flags + policy) and
main's MCP install block (microsoft#810, microsoft#814) pushed commands/install.py over
the 1525 LOC invariant enforced by tests/unit/install/
test_architecture_invariants. Per the test's own guidance, resolved by
extracting -- not trimming.

Conflicts:
- CHANGELOG.md: unioned the PR microsoft#700 Added entry with main's MCP entries.
- packages/apm-guide/.apm/skills/apm-usage/commands.md: merged both flag
  lists into a single --allow-insecure + --mcp row.
- src/apm_cli/commands/install.py: unioned the install() signature and
  docstring examples; kept both the InsecureDependencyPolicyError and
  click.UsageError except branches.
- tests/unit/test_install_command.py: kept TestAllowInsecureFlag and
  TestInstallMcpFlag as sibling classes (each with its own setup/
  teardown).

Architecture (commands/install.py LOC): 1572 -> 1496.
- Extracted F5 SSRF + F7 shell-metachar helpers to new dedicated module
  src/apm_cli/install/mcp_warnings.py (same pattern used in PR microsoft#809).
- commands/install.py re-binds the extracted symbols at module scope
  (warn_ssrf_url -> _warn_ssrf_url, warn_shell_metachars ->
  _warn_shell_metachars, _is_internal_or_metadata_host,
  _SHELL_METACHAR_TOKENS, _METADATA_HOSTS) so existing test patches
  against apm_cli.commands.install._warn_* keep working unchanged.

Tests: 4755/4755 unit + console pass (excludes the known pre-existing
test_user_scope_skips_workspace_runtimes failure on main, unrelated to
this PR or this merge).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
danielmeppiel added a commit to arika0093/apm that referenced this pull request Apr 21, 2026
PR microsoft#809 (shell-string MCP command validation) merged to main after the
previous merge commit on this branch. Re-merged to pick up the new tip.

Conflicts:
- src/apm_cli/install/mcp_warnings.py (add/add): both sides added this
  file. Took main's version -- it is the authoritative microsoft#809 copy and
  includes the FU4 extension (command= parameter on warn_shell_metachars)
  that my earlier manual extraction on this branch did not have.
- src/apm_cli/commands/install.py: kept main's re-bind import ordering
  since the two versions were trivially equivalent.

Tests: 4767/4767 unit + console pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
danielmeppiel added a commit that referenced this pull request Apr 21, 2026
- Bump version to 0.9.0 in pyproject.toml and uv.lock
- Move CHANGELOG [Unreleased] entries into [0.9.0] - 2026-04-21
- Add fresh empty [Unreleased] header
- Consolidate the two scattered ### Changed subsections into one
- Backfill missing entries: build-time self-update policy (#675),
  APM Review Panel skill instrumentation (#777)
- Backfill PR refs on a few entries that referenced issue numbers
- Promote the previously-orphaned 'apm install --mcp' / VS Code adapter /
  init Next Steps lines to consistent (#PR) attribution
- Remove stray blank line inside the ### Added block
- Credit external contributors inline (@arika0093 #700, @joostsijm #675,
  @edenfunf #788)

Highlights of 0.9.0:

BREAKING CHANGES
- MCP entry validation hardened (#807)
- Strict-by-default transport selection (#778)
- Whitespace stdio MCP commands now rejected at parse time (#809)

ADDED
- apm install --mcp for declarative MCP server addition (#810)
- --registry flag and MCP_REGISTRY_URL for custom MCP registries (#810)
- HTTP-dependency support via --allow-insecure dual opt-in (#700)
- apm install --ssh / --https flags for transport selection (#778)
- Multi-target apm.yml + --target flag (#628)
- Marketplace UX: view, outdated, validate (#514)
- Build-time self-update policy for package-manager distros (#675)
- APM Review Panel + 4 specialist personas (#777)

This PR is release machinery and is intentionally excluded from the
[0.9.0] section per .github/instructions/changelog.instructions.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change cli Deprecated: use area/cli. Kept for issue history; will be removed in milestone 0.10.0. dx enhancement Deprecated: use type/feature. Kept for issue history; will be removed in milestone 0.10.0.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(validation): warn or error on shell-string command in MCP stdio entries [FEATURE] Support stdio MCP servers in inline dependency declarations

2 participants