Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/shell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ jobs:
- name: Run tests/kimaki-no-default-channel.sh
run: ./tests/kimaki-no-default-channel.sh

runtime-guard:
name: runtime deactivation guard (#328)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests/runtime-guard.sh
run: ./tests/runtime-guard.sh

posture:
name: installed-source posture (#314)
runs-on: ubuntu-latest
Expand Down
130 changes: 130 additions & 0 deletions lib/runtime-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/bin/bash
# lib/runtime-guard.sh — protect the agent runtime from admin-UI deactivation.
#
# On a managed install the site owner has wp-admin access and no reason to
# understand the plugins list. Deactivating Data Machine there costs them their
# assistant's memory and tools, with no obvious way back.
#
# This guards the ADMIN UI only. `wp plugin deactivate data-machine` stays
# available because that is the operator's recovery path. The accident happens
# in wp-admin; the recovery happens on the CLI. See #328.
#
# Deliberately NOT an mu-plugin move (#323): WordPress fatal-error recovery
# pauses a plugin that fatals and cannot do that for an mu-plugin, so relocating
# the runtime would turn a broken assistant into a white screen on a live site.
#
# Public surface:
# runtime_guard_mu_plugin_path
# runtime_guard_sync # installs under managed, removes otherwise
#
# Honors DRY_RUN (logs intent, makes no changes).

runtime_guard_mu_plugin_path() {
if [ -z "${SITE_PATH:-}" ]; then
return 1
fi
printf '%s' "$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-runtime-guard.php"
}

# Plugin basenames to protect, one per line.
#
# `data-machine` is guarded unconditionally because wp-coding-agents installs it
# on every agent install — it is a property of the product, not of one site.
# Companions vary per install, so they are DISCOVERED from what is actually
# active rather than assumed. Naming a stack this tool has not inspected is the
# #320 mistake.
runtime_guard_plugins() {
if [ "${DRY_RUN:-false}" = true ] || [ -z "${SITE_PATH:-}" ] || [ ! -f "$SITE_PATH/wp-config.php" ]; then
printf '%s\n' 'data-machine/data-machine.php'
return 0
fi

local discovered
discovered="$(wp_cmd eval 'foreach ( (array) get_option( "active_plugins", array() ) as $p ) { if ( 0 === strpos( $p, "data-machine" ) ) { echo $p, "\n"; } }' 2>/dev/null || true)"

if [ -z "$discovered" ]; then
printf '%s\n' 'data-machine/data-machine.php'
return 0
fi

printf '%s\n' "$discovered" | tr -d '\r' | sed '/^$/d' | sort -u
}

runtime_guard_sync() {
local file
file="$(runtime_guard_mu_plugin_path)" || {
warn " runtime_guard_sync: SITE_PATH not set — skipping"
return 1
}

# Engineering installs have a developer at the keyboard; the guard would be
# noise. Remove any guard left behind by a posture switch.
if ! source_policy_is_managed; then
if [ -f "$file" ]; then
if [ "${DRY_RUN:-false}" = true ]; then
echo -e "${BLUE}[dry-run]${NC} Would remove runtime guard mu-plugin $file"
return 0
fi
rm -f "$file"
log " Removed runtime guard mu-plugin (posture is ${POSTURE:-engineering}): $file"
if [ -n "${UPDATED_ITEMS+x}" ]; then
UPDATED_ITEMS+=("runtime guard removed")
fi
fi
return 0
fi

local template="$SCRIPT_DIR/templates/wp-coding-agents-runtime-guard.php"
if [ ! -f "$template" ]; then
warn " runtime_guard_sync: missing template $template"
return 1
fi

local entries=""
local plugin
while IFS= read -r plugin; do
[ -n "$plugin" ] || continue
entries="${entries} '$(printf '%s' "$plugin" | sed "s/'/\\\\'/g")',"$'\n'
done < <(runtime_guard_plugins)

if [ -z "$entries" ]; then
warn " runtime_guard_sync: no runtime plugins resolved — not writing an empty guard"
return 1
fi

local rendered
rendered="$(RUNTIME_GUARD_ENTRIES="$entries" python3 - "$template" <<'PY'
import os, pathlib, sys
template = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
begin = "\t\t// BEGIN wp-coding-agents-guarded-plugins\n"
end = "\t\t// END wp-coding-agents-guarded-plugins"
start = template.index(begin) + len(begin)
stop = template.index(end)
sys.stdout.write(template[:start] + os.environ["RUNTIME_GUARD_ENTRIES"] + template[stop:])
PY
)" || {
warn " runtime_guard_sync: could not render guard template"
return 1
}

if [ "${DRY_RUN:-false}" = true ]; then
echo -e "${BLUE}[dry-run]${NC} Would install runtime guard mu-plugin at $file guarding:"
printf '%s' "$entries" | sed 's/^/ /'
return 0
fi

local dir="${file%/*}"
mkdir -p "$dir"

if [ -f "$file" ] && printf '%s\n' "$rendered" | cmp -s - "$file"; then
service_file_normalize_perms "$file"
return 0
fi

printf '%s\n' "$rendered" > "$file"
service_file_normalize_perms "$file"
log " Installed runtime guard mu-plugin: $file"
if [ -n "${UPDATED_ITEMS+x}" ]; then
UPDATED_ITEMS+=("runtime deactivation guard")
fi
}
3 changes: 2 additions & 1 deletion setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Source shared modules
for lib in common detect source-policy wordpress infrastructure data-machine carried-plugins homeboy ai-gateway skills summary cli-transport cli-channel runtime-signature agents-md-guidance; do
for lib in common detect source-policy wordpress infrastructure data-machine carried-plugins homeboy ai-gateway skills summary cli-transport cli-channel runtime-signature runtime-guard agents-md-guidance; do
source "$SCRIPT_DIR/lib/${lib}.sh"
done

Expand Down Expand Up @@ -512,6 +512,7 @@ runtime_generate_instructions
runtime_merge_mcp_servers
install_skills
cli_transport_install
runtime_guard_sync
install_chat_bridge
datamachine_worker_install
print_summary
124 changes: 124 additions & 0 deletions templates/wp-coding-agents-runtime-guard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php
/**
* Plugin Name: wp-coding-agents — runtime deactivation guard
* Description: Prevents the agent runtime from being deactivated or deleted
* through the wp-admin plugins screen on managed installs.
* Managed by wp-coding-agents setup/upgrade — do not edit by hand.
*
* WHY THIS EXISTS
*
* On a managed install the site owner has wp-admin access and no reason to
* understand the plugins list. Deactivating Data Machine there costs them their
* assistant's memory and tools, with no obvious way back for a non-technical
* user.
*
* WHY NOT AN MU-PLUGIN MOVE
*
* Relocating the runtime into mu-plugins would also prevent deactivation, but
* WordPress fatal-error recovery PAUSES a plugin that fatals and cannot do that
* for an mu-plugin. Making the runtime unkillable would also make it
* unpausable, turning a broken assistant into a white screen on a live site.
* See Extra-Chill/wp-coding-agents#328 and #323.
*
* WP-CLI IS DELIBERATELY NOT GUARDED
*
* `wp plugin deactivate data-machine` stays available. That is the operator's
* recovery path and was used during the managed-posture rollout. This guard
* targets the admin UI, which is where the accident happens, not the CLI, which
* is where recovery happens.
*
* @package wp-coding-agents
*/

defined( 'ABSPATH' ) || exit;

/**
* Plugin basenames this install protects.
*
* Written by wp-coding-agents at setup/upgrade time between the markers below.
*
* @return string[]
*/
function wp_coding_agents_guarded_runtime_plugins(): array {
$guarded = array(
// BEGIN wp-coding-agents-guarded-plugins
// END wp-coding-agents-guarded-plugins
);

/**
* Filters the plugins protected from admin-UI deactivation.
*
* @param string[] $guarded Plugin basenames, e.g. 'data-machine/data-machine.php'.
*/
$filtered = apply_filters( 'wp_coding_agents_guarded_runtime_plugins', $guarded );

return is_array( $filtered ) ? array_values( array_filter( array_map( 'strval', $filtered ) ) ) : $guarded;
}

/**
* Replace the Deactivate and Delete links with a short explanation.
*
* The owner is told why rather than shown a row with missing controls, which
* reads as a broken page.
*
* @param string[] $actions Action links keyed by action.
* @param string $plugin_file Plugin basename.
* @return string[]
*/
function wp_coding_agents_guard_plugin_action_links( $actions, $plugin_file ) {
if ( ! is_array( $actions ) || ! in_array( (string) $plugin_file, wp_coding_agents_guarded_runtime_plugins(), true ) ) {
return $actions;
}

unset( $actions['deactivate'], $actions['delete'] );

$actions['wp-coding-agents-guard'] = '<span aria-label="' . esc_attr__( 'Required by this site\'s AI assistant', 'wp-coding-agents' ) . '">'
. esc_html__( 'Required by your assistant', 'wp-coding-agents' )
. '</span>';

return $actions;
}
add_filter( 'plugin_action_links', 'wp_coding_agents_guard_plugin_action_links', 100, 2 );
add_filter( 'network_admin_plugin_action_links', 'wp_coding_agents_guard_plugin_action_links', 100, 2 );

/**
* Reject a guarded deactivation or deletion request.
*
* Hiding the links is presentation only — the request can still be issued by
* URL, by a bulk action, or by any code calling deactivate_plugins(). This is
* the half that actually holds.
*
* Only wp-admin requests are refused. WP-CLI, WP-Cron, and REST are left alone
* so operator recovery and programmatic management keep working.
*
* @param string|string[] $plugins Plugin basename(s) being acted on.
* @return void
*/
function wp_coding_agents_block_guarded_deactivation( $plugins ): void {
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() ) {
return;
}

if ( ! function_exists( 'is_admin' ) || ! is_admin() ) {
return;
}

$guarded = wp_coding_agents_guarded_runtime_plugins();
$requested = is_array( $plugins ) ? $plugins : array( $plugins );
$blocked = array_intersect( array_map( 'strval', $requested ), $guarded );

if ( empty( $blocked ) ) {
return;
}

wp_die(
esc_html__(
'This plugin runs your site\'s AI assistant and cannot be deactivated or deleted from here. Ask your site operator if you need it changed.',
'wp-coding-agents'
),
esc_html__( 'Action not permitted', 'wp-coding-agents' ),
array( 'response' => 403, 'back_link' => true )
);
}
add_action( 'deactivate_plugin', 'wp_coding_agents_block_guarded_deactivation', 1 );
add_action( 'delete_plugin', 'wp_coding_agents_block_guarded_deactivation', 1 );
Loading
Loading