Skip to content

feat!: add a destructive marker to #[Command] and refuse marked commands in mcp - #524

Merged
markshust merged 1 commit into
developfrom
feature/destructive-command-marker
Oct 6, 2026
Merged

markshust merged 1 commit into
developfrom
feature/destructive-command-marker

Conversation

@markshust

Copy link
Copy Markdown
Collaborator

Summary

  • #[Command] takes a new destructive: bool = false argument (picked over a separate #[Destructive] attribute so the command's whole contract lives in one attribute and flows through the existing CommandDefinition/discovery pipeline). It is exposed as CommandDefinition::$destructive and stored in the discovery cache.
  • Every shipped state-changing command is marked: db:migrate, db:reset, db:rollback, db:rebuild, db:seed, cache:clear, page-cache:clear, page-cache:purge, queue:clear, log:clear, session:gc, auth:clear-tokens, discovery:clear, indexer:rebuild, admin-auth:permissions:sync, devai:install. (db:migrate, devai:install and admin-auth:permissions:sync were already treated as destructive by MCP because they declare force; marking them keeps that.)
  • MCP run_console_command reads $definition->destructive instead of looking for a force flag, so cache:clear, queue:clear, log:clear, page-cache:clear... now also need mcp.console.allow_destructive.
  • --force stays the CLI confirmation mechanism. DestructiveCommandGuard itself never inferred anything from option names (it is called explicitly by each command), so its runtime behaviour is unchanged. Its docblock now requires guarded commands to carry the marker, and the repo test enforces that for shipped commands.
  • New repo-level test tests/DestructiveCommandMarkerTest.php reflects every #[Command] class under packages/*/src and fails when a command named *:clear, *:reset, *:rollback, *:rebuild, *:fresh, *:seed, *:truncate, *:purge or *:clear-tokens, or one that depends on DestructiveCommandGuard, lacks the marker.
  • Docs: cli (new "Destructive Commands" section), core, mcp, database.

Breaking

  • MCP: an app that allowlisted a state-changing command without a force flag (e.g. cache:clear) in mcp.console.allowed_commands now gets it refused. Escape hatch: set mcp.console.allow_destructive to true (MCP_ALLOW_DESTRUCTIVE=true). That enables only destructive commands that are also allowlisted.
  • Discovery cache: DiscoveryCache::CACHE_VERSION goes from 3 to 4 because command records gain a destructive field. A cache compiled by an older version fails boot with the usual version-mismatch DiscoveryCacheException. Run marko discovery:cache on deploy (already the documented step).

Test plan

  • tests/DestructiveCommandMarkerTest.php: suffix rule, guard-dependency rule, and read-only commands stay unmarked (red before the commands were marked)
  • RunConsoleCommandToolTest: refuses a destructive command with no force flag (cache:clear), refuses one reached through an alias, runs a force-flag command that is not marked destructive, existing allow_destructive cases
  • core: attribute/definition defaults, discovery reads the marker, cache round-trips it and rejects records missing it or with a non-bool value
  • composer test, composer cs:check, composer phpstan green locally

Fixes #516

🤖 Generated with Claude Code

https://claude.ai/code/session_01KyjR4cJTfro54iFZ9CWkv1

…ds in mcp

#[Command] takes a new `destructive: bool = false` argument. It is copied onto
CommandDefinition (and the discovery cache, whose format version goes to 4) so
any caller can tell a state-changing command from a read-only one without
guessing from its name or options.

Every shipped command that changes or deletes stored state is now marked:
db:migrate, db:reset, db:rollback, db:rebuild, db:seed, cache:clear,
page-cache:clear, page-cache:purge, queue:clear, log:clear, session:gc,
auth:clear-tokens, discovery:clear, indexer:rebuild,
admin-auth:permissions:sync and devai:install.

The MCP run_console_command tool now refuses commands marked destructive
unless mcp.console.allow_destructive is on, instead of inferring
destructiveness from a declared `force` flag. That closes the gap where
cache:clear, queue:clear, log:clear and page-cache:clear were guarded only by
the allowlist. --force stays the CLI confirmation mechanism via
DestructiveCommandGuard.

A repo-level test fails when a shipped command named *:clear, *:reset,
*:rollback, *:rebuild, *:fresh, *:seed, *:truncate, *:purge or *:clear-tokens,
or one that depends on DestructiveCommandGuard, lacks the marker.

Fixes #516

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyjR4cJTfro54iFZ9CWkv1
@github-actions github-actions Bot added enhancement New feature or request breaking Introduces a breaking change labels Oct 6, 2026
@markshust
markshust merged commit f8420fb into develop Oct 6, 2026
5 checks passed
@markshust
markshust deleted the feature/destructive-command-marker branch October 6, 2026 15:16
etienne-scherrer pushed a commit to etienne-org/marko that referenced this pull request Oct 7, 2026
…and marker

marko-php#524 made run_console_command read #[Command(destructive: true)] instead of
inferring from a force flag; the config comment still described the old rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyjR4cJTfro54iFZ9CWkv1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Introduces a breaking change enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Core "destructive" marker on #[Command] so MCP and other callers can refuse state-changing commands uniformly

1 participant