in the menu) avoids inlining the SVG as * an encoded data URI, which the source-tree security guard forbids. The mark is a white * shield that reads on both the dark menu and the blue current-item background; the full * colored logo lives on the wp.org listing and the website, not here. Falls back to a * core Dashicon if the asset is somehow missing so the menu never breaks. * * @return string An asset URL for the brand icon, or a dashicons-* slug fallback. */ function aafm_admin_menu_icon(): string { $rel = 'assets/wp-admin-icon.svg'; if ( ! is_readable( AAFM_PLUGIN_DIR . $rel ) ) { return 'dashicons-superhero'; } return AAFM_PLUGIN_URL . $rel; } /** * Register the admin pages as a dedicated top-level menu, one submenu per tab. * * @return void */ function aafm_register_admin_menu(): void { $icon = aafm_admin_menu_icon(); add_menu_page( __( 'Agent Abilities for MCP', 'agent-abilities-for-mcp' ), __( 'Agent Abilities for MCP', 'agent-abilities-for-mcp' ), 'manage_options', 'agent-abilities-for-mcp', 'aafm_render_admin_page', $icon, 80 ); // One submenu per tab; the Dashboard submenu reuses the parent slug, the rest carry // their tab in the slug so the link is admin.php?page=…&tab=… and the parent page renders. foreach ( aafm_admin_tabs() as $slug => $label ) { $menu_slug = ( 'dashboard' === $slug ) ? 'agent-abilities-for-mcp' : 'agent-abilities-for-mcp&tab=' . $slug; add_submenu_page( 'agent-abilities-for-mcp', $label, $label, 'manage_options', $menu_slug, 'aafm_render_admin_page' ); } } /** * Add quick links to the plugin's row on the Plugins screen. * * Prepends Getting Started / Abilities / Integrations / Settings before WordPress's own * row actions (Deactivate), so the most-used destinations are one click from the list. * * @param array $actions The existing row action links, keyed by handle. * @return array The links with ours prepended. */ function aafm_plugin_action_links( array $actions ): array { $base = 'admin.php?page=agent-abilities-for-mcp'; $links = array( 'aafm-getting-started' => sprintf( '%s', esc_url( admin_url( $base ) ), esc_html__( 'Getting Started', 'agent-abilities-for-mcp' ) ), 'aafm-abilities' => sprintf( '%s', esc_url( admin_url( $base . '&tab=abilities' ) ), esc_html__( 'Abilities', 'agent-abilities-for-mcp' ) ), 'aafm-integrations' => sprintf( '%s', esc_url( admin_url( $base . '&tab=integrations' ) ), esc_html__( 'Integrations', 'agent-abilities-for-mcp' ) ), 'aafm-settings' => sprintf( '%s', esc_url( admin_url( $base . '&tab=settings' ) ), esc_html__( 'Settings', 'agent-abilities-for-mcp' ) ), ); return array_merge( $links, $actions ); } /** * Highlight the submenu item for the active tab. * * WordPress matches the current submenu only on the `page` query var, so every tab * would otherwise highlight the first item (Dashboard). This returns the same slug * `add_submenu_page()` registered for the active tab so the correct item is marked * current; for the dashboard, an absent tab, or an unknown tab it returns the bare * parent slug. * * @param string $submenu_file The submenu file WordPress is about to mark current. * @return string Tab-aware slug on our page, otherwise the input unchanged. */ function aafm_highlight_tab_submenu( $submenu_file ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only menu highlighting, no state change. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( (string) $_GET['page'] ) ) : ''; if ( 'agent-abilities-for-mcp' !== $page ) { return $submenu_file; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only menu highlighting, no state change. $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( (string) $_GET['tab'] ) ) : 'dashboard'; if ( 'dashboard' === $tab || ! in_array( $tab, array_keys( aafm_admin_tabs() ), true ) ) { return 'agent-abilities-for-mcp'; } return 'agent-abilities-for-mcp&tab=' . $tab; } /** * Enqueue admin assets only on our top-level admin page. * * @param string $hook Current admin page hook suffix. * @return void */ function aafm_enqueue_admin_assets( string $hook ): void { if ( 'toplevel_page_agent-abilities-for-mcp' !== $hook ) { return; } // Use filemtime() as the cache-buster so ?ver= changes whenever the file changes, // defeating both the browser cache and any CDN/Cloudflare edge cache. A fixed // AAFM_VERSION string does not change on every deploy, so // old asset bytes stay cached across redeploys without this. Fall back to AAFM_VERSION // when filemtime() returns false (opcache edge or path anomaly). $css_path = AAFM_PLUGIN_DIR . 'includes/admin/assets/admin.css'; $js_path = AAFM_PLUGIN_DIR . 'includes/admin/assets/admin.js'; $css_ver = filemtime( $css_path ); $js_ver = filemtime( $js_path ); wp_enqueue_style( 'aafm-admin', AAFM_PLUGIN_URL . 'includes/admin/assets/admin.css', array(), (string) ( false !== $css_ver ? $css_ver : AAFM_VERSION ) ); wp_enqueue_script( 'aafm-admin', AAFM_PLUGIN_URL . 'includes/admin/assets/admin.js', array(), (string) ( false !== $js_ver ? $js_ver : AAFM_VERSION ), true ); wp_localize_script( 'aafm-admin', 'aafmAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'aafm_admin' ), // Every row's ability picker (including one added client-side via "Add scope") is // built from this one shared, subject-grouped list rather than duplicating the // grouping in both PHP and JS - see aafm_allowlist_ability_catalog()'s own docblock. 'allowlistCatalog' => aafm_allowlist_ability_catalog(), 'i18n' => array( 'saving' => __( 'Saving…', 'agent-abilities-for-mcp' ), 'playbookActive' => __( 'Active', 'agent-abilities-for-mcp' ), /* translators: %s: comma-separated ability names. */ 'playbookChosen' => __( 'Chosen: %s.', 'agent-abilities-for-mcp' ), 'playbookChosenNone' => __( 'No abilities chosen yet.', 'agent-abilities-for-mcp' ), 'playbookOffNoteOne' => __( '1 of the abilities you chose is off or needs a plugin that is not active. Agents cannot use it until you turn it on on the Abilities or Integrations tab.', 'agent-abilities-for-mcp' ), /* translators: %d: how many chosen abilities agents cannot use yet. */ 'playbookOffNoteMany' => __( '%d of the abilities you chose are off or need a plugin that is not active. Agents cannot use them until you turn them on on the Abilities or Integrations tab.', 'agent-abilities-for-mcp' ), /* translators: %s: playbook title. */ 'playbookEditLabel' => __( 'Edit %s', 'agent-abilities-for-mcp' ), /* translators: %s: playbook title. */ 'playbookDeleteLabel' => __( 'Delete %s', 'agent-abilities-for-mcp' ), 'playbookOff' => __( 'Off', 'agent-abilities-for-mcp' ), 'playbookEdit' => __( 'Edit', 'agent-abilities-for-mcp' ), 'playbookDelete' => __( 'Delete', 'agent-abilities-for-mcp' ), 'playbookDeleted' => __( 'Deleted', 'agent-abilities-for-mcp' ), 'playbookAdded' => __( 'Added. It is switched Off until you turn it on.', 'agent-abilities-for-mcp' ), 'playbookAddedTitle' => __( 'Already in your playbooks.', 'agent-abilities-for-mcp' ), 'playbookDiscard' => __( 'Discard your unsaved changes to this playbook?', 'agent-abilities-for-mcp' ), 'playbookDeleteConfirm' => __( 'Delete this playbook? Agents will no longer be able to load it.', 'agent-abilities-for-mcp' ), /* translators: %d: how many abilities the playbook names. */ 'playbookUsesOne' => __( 'Uses %d ability', 'agent-abilities-for-mcp' ), /* translators: %d: how many abilities the playbook names. */ 'playbookUsesMany' => __( 'Uses %d abilities', 'agent-abilities-for-mcp' ), /* translators: 1: "Uses N abilities". 2: how many of them are off on this site. */ 'playbookOffOne' => __( '%1$s, %2$d is off here', 'agent-abilities-for-mcp' ), /* translators: 1: "Uses N abilities". 2: how many of them are off on this site. */ 'playbookOffMany' => __( '%1$s, %2$d are off here', 'agent-abilities-for-mcp' ), 'saved' => __( 'Saved', 'agent-abilities-for-mcp' ), 'errorSaving' => __( 'Error saving', 'agent-abilities-for-mcp' ), 'creating' => __( 'Creating…', 'agent-abilities-for-mcp' ), 'checking' => __( 'Checking…', 'agent-abilities-for-mcp' ), 'cleared' => __( 'Cleared', 'agent-abilities-for-mcp' ), 'error' => __( 'Error', 'agent-abilities-for-mcp' ), 'requestFailed' => __( 'Request failed.', 'agent-abilities-for-mcp' ), 'grantActive' => __( 'Active', 'agent-abilities-for-mcp' ), 'grantPaused' => __( 'Paused', 'agent-abilities-for-mcp' ), 'grantExpired' => __( 'Expired', 'agent-abilities-for-mcp' ), 'grantPause' => __( 'Pause', 'agent-abilities-for-mcp' ), 'grantResume' => __( 'Resume', 'agent-abilities-for-mcp' ), /* translators: %s: user login and app name. */ 'grantPauseFor' => __( 'Pause %s', 'agent-abilities-for-mcp' ), /* translators: %s: user login and app name. */ 'grantResumeFor' => __( 'Resume %s', 'agent-abilities-for-mcp' ), 'grantNothingChanged' => __( 'Nothing to save.', 'agent-abilities-for-mcp' ), 'appPasswordRevokeConfirm' => __( 'Revoke this Application Password? Anything using it stops working right away.', 'agent-abilities-for-mcp' ), 'pauseAll' => __( 'Pause all access', 'agent-abilities-for-mcp' ), 'resumeAll' => __( 'Resume all access', 'agent-abilities-for-mcp' ), 'pauseAllConfirm' => __( 'Pause all MCP access through this site? Every connected agent is refused until you resume.', 'agent-abilities-for-mcp' ), 'dcrOffConfirm' => __( 'Turn off dynamic client registration? ChatGPT and Claude can no longer register themselves, so new connections from them will fail until you turn it back on.', 'agent-abilities-for-mcp' ), 'settingsNotSaved' => __( 'Could not save. Your previous settings are still in effect.', 'agent-abilities-for-mcp' ), 'allowlistEmptied' => __( 'Saved, but every line was dropped as invalid. The allowlist is now empty, so connections from anywhere are allowed.', 'agent-abilities-for-mcp' ), /* translators: %d: number of allowlist lines that were dropped as invalid. */ 'allowlistDropped' => __( 'Saved. Dropped %d line(s) that were not a valid IP or range - check the allowlist.', 'agent-abilities-for-mcp' ), /* translators: %d: the new agent user's numeric ID. */ 'userCreated' => __( 'Created user #%d. Now create its Application Password under Users → Profile.', 'agent-abilities-for-mcp' ), 'editUser' => __( 'Edit user', 'agent-abilities-for-mcp' ), /* translators: 1: current page number, 2: total number of pages. */ 'pagerStatus' => __( 'Page %1$s of %2$s', 'agent-abilities-for-mcp' ), 'loadingPage' => __( 'Loading…', 'agent-abilities-for-mcp' ), 'noActivity' => __( 'No activity recorded yet.', 'agent-abilities-for-mcp' ), 'noMatches' => __( 'No entries match these filters.', 'agent-abilities-for-mcp' ), 'searchTooShort' => __( 'Type at least 2 characters to search.', 'agent-abilities-for-mcp' ), // Activity Log: Live view and summary. 'liveStopped' => __( 'Live view stopped.', 'agent-abilities-for-mcp' ), 'liveTimedOut' => __( 'Live view paused after 15 minutes. Turn it on again to keep watching.', 'agent-abilities-for-mcp' ), 'livePage' => __( 'Live view only follows page 1.', 'agent-abilities-for-mcp' ), 'liveFailed' => __( 'Live view stopped after repeated errors. Reload the page.', 'agent-abilities-for-mcp' ), 'noCalls' => __( 'No calls in the last 7 days.', 'agent-abilities-for-mcp' ), 'summaryUnavailable' => __( 'Summary unavailable.', 'agent-abilities-for-mcp' ), 'liveNeedsRange' => __( 'Live view needs a date range when a search or user filter is applied.', 'agent-abilities-for-mcp' ), /* translators: 1: HTTP status code, 2: number of tools visible in the admin view. */ 'connectionOk' => __( 'Reachable (HTTP %1$s) - %2$s tool(s) in your admin view.', 'agent-abilities-for-mcp' ), /* translators: %s: HTTP status code returned by the endpoint. */ 'connectionNoTools' => __( 'Endpoint answered HTTP %s but did not return a tool list.', 'agent-abilities-for-mcp' ), 'connectionNoLogin' => __( 'The endpoint is on a different address, so this test ran without your login.', 'agent-abilities-for-mcp' ), 'connectionRedirected' => __( 'The endpoint redirected, so the test stopped before sending your login anywhere else. Run the Connection Doctor to see where it goes.', 'agent-abilities-for-mcp' ), // Connection Doctor. 'doctorPass' => __( 'Pass', 'agent-abilities-for-mcp' ), 'doctorWarn' => __( 'Warning', 'agent-abilities-for-mcp' ), 'doctorFail' => __( 'Fail', 'agent-abilities-for-mcp' ), 'doctorSkip' => __( 'Could not test', 'agent-abilities-for-mcp' ), 'doctorWhoYou' => __( 'You can fix this', 'agent-abilities-for-mcp' ), 'doctorWhoHost' => __( 'Your host can fix this', 'agent-abilities-for-mcp' ), 'doctorWhoPlugin' => __( 'A security plugin may be the cause', 'agent-abilities-for-mcp' ), 'doctorCopySnippet' => __( 'Copy snippet', 'agent-abilities-for-mcp' ), 'doctorError' => __( 'The check could not run. Reload the page and try again.', 'agent-abilities-for-mcp' ), // Other plugins: review card and read-only starter. 'bridgeWorking' => __( 'Working…', 'agent-abilities-for-mcp' ), /* translators: 1: how many read tools, 2: plugin name. */ 'bridgeStarterOn' => __( 'This turns on %1$s read tools from %2$s.', 'agent-abilities-for-mcp' ), /* translators: %s: plugin name. */ 'bridgeStarterOnOne' => __( 'This turns on 1 read tool from %s.', 'agent-abilities-for-mcp' ), /* translators: %s: how many of the plugin's tools. */ 'bridgeStarterAlsoOff' => __( 'It also turns off %s of its tools that are on now.', 'agent-abilities-for-mcp' ), 'bridgeStarterAlsoOffOne' => __( 'It also turns off 1 of its tools that is on now.', 'agent-abilities-for-mcp' ), /* translators: 1: how many tools, 2: plugin name. */ 'bridgeStarterOffOnly' => __( 'This turns off %1$s tools from %2$s and turns on none.', 'agent-abilities-for-mcp' ), /* translators: %s: plugin name. */ 'bridgeStarterOffOnlyOne' => __( 'This turns off 1 tool from %s and turns on none.', 'agent-abilities-for-mcp' ), /* translators: %s: plugin name. */ 'bridgeStarterNoop' => __( 'Nothing to change: %s already allows only its read tools.', 'agent-abilities-for-mcp' ), /* translators: %s: plugin name. */ 'bridgeStarterNoopNoReads' => __( 'Nothing to change: %s labels none of its tools as read-only, and none of them is on.', 'agent-abilities-for-mcp' ), /* translators: 1: how many saved selections, 2: plugin name. */ 'bridgeStarterRemoved' => __( 'This removes %1$s saved selections from %2$s, for tools that are not on now, so they will not come back on.', 'agent-abilities-for-mcp' ), /* translators: %s: plugin name. */ 'bridgeStarterRemovedOne' => __( 'This removes 1 saved selection from %s, for a tool that is not on now, so it will not come back on.', 'agent-abilities-for-mcp' ), /* translators: %s: how many saved selections. */ 'bridgeStarterAlsoRemoved' => __( 'It also removes %s saved selections for tools that are not on now, so they will not come back on.', 'agent-abilities-for-mcp' ), 'bridgeStarterOneRemoved' => __( 'It also removes 1 saved selection for a tool that is not on now, so it will not come back on.', 'agent-abilities-for-mcp' ), /* translators: %s: how many tools the starter turned off. */ 'bridgeStarterTurnedOff' => __( 'Tools turned off: %s.', 'agent-abilities-for-mcp' ), /* translators: %s: how many saved selections the starter removed. */ 'bridgeStarterRemovedLine' => __( 'Saved selections removed: %s.', 'agent-abilities-for-mcp' ), 'bridgeStarterReload' => __( 'Reload the page to update the Unavailable group.', 'agent-abilities-for-mcp' ), /* translators: 1: how many tools, 2: plugin name. */ 'bridgeReviewed' => __( '%1$s tools from %2$s marked as reviewed.', 'agent-abilities-for-mcp' ), /* translators: %s: plugin name. */ 'bridgeReviewedOne' => __( '1 tool from %s marked as reviewed.', 'agent-abilities-for-mcp' ), 'bridgeReviewedAll' => __( 'All new tools marked as reviewed.', 'agent-abilities-for-mcp' ), 'bridgeNothingLeft' => __( 'Nothing left to review.', 'agent-abilities-for-mcp' ), // Needs review inbox. 'reviewDiscardConfirm' => __( 'Move this item to the Trash?', 'agent-abilities-for-mcp' ), 'reviewFailed' => __( 'That did not work. Reload the page and try again.', 'agent-abilities-for-mcp' ), /* translators: %s: comma-separated field names. */ 'undoRevertConfirm' => __( 'Revert this change? It will put back %s. Notifications, webhooks and anything already read are not recalled.', 'agent-abilities-for-mcp' ), 'undoRevertGroupConfirm' => __( 'Revert every change in this group, newest first? A post edited since is skipped and listed. Notifications, webhooks and anything already read are not recalled.', 'agent-abilities-for-mcp' ), 'undoClearConfirm' => __( 'Clear all change history? Nothing can be reverted afterwards. This cannot be undone.', 'agent-abilities-for-mcp' ), 'undoWorking' => __( 'Working...', 'agent-abilities-for-mcp' ), 'undoReverted' => __( 'Reverted.', 'agent-abilities-for-mcp' ), 'undoRevertedWithDiff' => __( 'Reverted. WordPress stored these a little differently:', 'agent-abilities-for-mcp' ), 'undoPillReverted' => __( 'Reverted', 'agent-abilities-for-mcp' ), 'undoGroupDone' => __( 'Group reverted. Reload to see the new state.', 'agent-abilities-for-mcp' ), /* translators: %s: how many changes of the group were reverted. */ 'undoGroupPartial' => __( '%s of the changes were reverted. Not reverted:', 'agent-abilities-for-mcp' ), 'undoCleared' => __( 'History cleared.', 'agent-abilities-for-mcp' ), 'undoEmpty' => __( 'No changes recorded yet. When an agent edits a post, a term, a custom field or SEO data, the change shows up here.', 'agent-abilities-for-mcp' ), /* translators: %s: error message returned by the server. */ 'errorWithMessage' => __( 'Error: %s', 'agent-abilities-for-mcp' ), 'errorUnknown' => __( 'unknown', 'agent-abilities-for-mcp' ), 'copyCopied' => __( 'Copied', 'agent-abilities-for-mcp' ), 'copyFallback' => __( 'Press Ctrl+C', 'agent-abilities-for-mcp' ), 'resetConfirm' => __( 'Reset the plugin to defaults? This clears every setting, your enabled abilities, the whole activity log, the change history, and every OAuth client, token, and consent. Your agent user and any content it created are kept. This cannot be undone.', 'agent-abilities-for-mcp' ), 'resetWorking' => __( 'Resetting…', 'agent-abilities-for-mcp' ), 'resetDone' => __( 'Reset. Reloading…', 'agent-abilities-for-mcp' ), 'resetFailed' => __( 'Reset failed.', 'agent-abilities-for-mcp' ), 'sectionToggleConfirm' => __( 'This section includes destructive abilities (trash/delete). Enable all of them?', 'agent-abilities-for-mcp' ), 'integrationToggleConfirm' => __( 'These abilities can read and change personal data such as customer details and orders. Turn all of them on?', 'agent-abilities-for-mcp' ), 'revokeClientConfirm' => __( 'Revoke this client? It is turned off and its active sessions end right away.', 'agent-abilities-for-mcp' ), 'revokeGrantConfirm' => __( 'Revoke this grant? The user will have to approve again to reconnect.', 'agent-abilities-for-mcp' ), 'revokeFailed' => __( 'Could not revoke. Please try again.', 'agent-abilities-for-mcp' ), 'statusRevoked' => __( 'Revoked', 'agent-abilities-for-mcp' ), 'agentToggleFailed' => __( 'Could not save. Please try again.', 'agent-abilities-for-mcp' ), /* translators: %s: number of abilities matching the current search. */ 'abilitiesSearchCount' => __( '%s abilities match.', 'agent-abilities-for-mcp' ), 'abilitiesSearchNone' => __( 'No abilities match.', 'agent-abilities-for-mcp' ), // Ability allowlist picker (Connections tab). 'allowlistAll' => __( 'All abilities (no narrowing)', 'agent-abilities-for-mcp' ), 'allowlistSearch' => __( 'Search abilities…', 'agent-abilities-for-mcp' ), 'allowlistZeroSelected' => __( 'No abilities selected. Saving now will block this scope from every ability.', 'agent-abilities-for-mcp' ), /* translators: %s: number of abilities selected in the allowlist picker. */ 'allowlistSelectedCount' => __( '%s selected', 'agent-abilities-for-mcp' ), // A row added client-side (before reload) used to build this // label from a hardcoded English template literal instead of these translated // strings, which is what every server-rendered row already used. /* translators: %s: role display name. */ 'allowlistRoleLabel' => __( 'Role: %s', 'agent-abilities-for-mcp' ), /* translators: %s: OAuth client id or display name. */ 'allowlistConnectionLabel' => __( 'Connection: %s', 'agent-abilities-for-mcp' ), 'pagerPrevious' => __( 'Previous', 'agent-abilities-for-mcp' ), 'pagerNext' => __( 'Next', 'agent-abilities-for-mcp' ), /* translators: 1: first row number shown, 2: last row number shown, 3: total rows. */ 'oauthPagerCount' => __( 'Showing %1$s-%2$s of %3$s', 'agent-abilities-for-mcp' ), // Quick Connect wizard. 'qcInProgress' => __( 'In progress', 'agent-abilities-for-mcp' ), 'qcNotStarted' => __( 'Not started', 'agent-abilities-for-mcp' ), 'qcOauthOn' => __( 'OAuth is on. Copy the URL, then continue.', 'agent-abilities-for-mcp' ), 'qcOauthOff' => __( 'OAuth is off. Use the application-password path below, or turn OAuth on.', 'agent-abilities-for-mcp' ), 'qcUserCreated' => __( 'mcp-agent created (subscriber)', 'agent-abilities-for-mcp' ), 'qcMethodOauth' => __( 'OAuth', 'agent-abilities-for-mcp' ), 'qcMethodAppPassword' => __( 'Application password', 'agent-abilities-for-mcp' ), 'qcOn' => __( 'On', 'agent-abilities-for-mcp' ), 'qcOff' => __( 'Off', 'agent-abilities-for-mcp' ), // Abilities tab: the one Save button, its dirty pill and its result lines. 'unsavedChanges' => __( 'Unsaved changes', 'agent-abilities-for-mcp' ), 'noChangesToSave' => __( 'No changes to save.', 'agent-abilities-for-mcp' ), /* translators: %s: names of the sections that were not saved, for example "Exposed meta keys (Content) and Exposed user meta keys (Users)". */ 'notSavedSome' => __( 'Not saved: %s. Everything else was saved.', 'agent-abilities-for-mcp' ), /* translators: %s: names of the sections that were not saved, for example "Exposed meta keys (Content)". */ 'notSavedAll' => __( 'Not saved: %s.', 'agent-abilities-for-mcp' ), /* translators: 1: section name, for example "Exposed meta keys". 2: name of the sub-tab the section sits in, for example "Content". */ 'sectionInTab' => __( '%1$s (%2$s)', 'agent-abilities-for-mcp' ), /* translators: %s: the reason the server gave for not saving a section; it has its own final punctuation. */ 'notSavedSection' => __( 'Not saved: %s', 'agent-abilities-for-mcp' ), 'notSavedSectionGeneric' => __( 'Not saved. Your changes are still here.', 'agent-abilities-for-mcp' ), 'saveNetworkError' => __( 'Could not save. Check your connection and try again. Your changes are still on the page.', 'agent-abilities-for-mcp' ), 'saveExpired' => __( 'Could not save because this page has expired. Copy any unsaved changes, reload the page, and save again.', 'agent-abilities-for-mcp' ), 'savedCleaned' => __( 'Saved. Some entries were not accepted and were removed from the list.', 'agent-abilities-for-mcp' ), 'savedWithNewerEdits' => __( 'Saved. Changes you made while saving are not saved yet.', 'agent-abilities-for-mcp' ), 'sectionAbilities' => __( 'Enabled abilities', 'agent-abilities-for-mcp' ), 'sectionPostTypes' => __( 'Exposed content types', 'agent-abilities-for-mcp' ), 'sectionMetaKeys' => __( 'Exposed meta keys', 'agent-abilities-for-mcp' ), 'sectionUserKeys' => __( 'Exposed user meta keys', 'agent-abilities-for-mcp' ), 'sectionTermKeys' => __( 'Exposed term meta keys', 'agent-abilities-for-mcp' ), ), ) ); } /** * Read the raw persisted enabled-abilities option, sanitized but NOT filtered by registry. * * The aafm_get_enabled_abilities() accessor intersects the stored option against the live, * host-gated registry, so an ability whose host integration is currently inactive is invisible * there even though it is still sitting in the option untouched. The save-preservation logic * below needs to see those inactive-host entries in order to carry them forward (M9), so it * reads the option directly here instead of going through that filtered accessor. * * @return array */ function aafm_get_stored_enabled_abilities_raw(): array { $normalize = static function ( $value ): array { return is_array( $value ) ? array_values( array_filter( array_map( 'strval', $value ) ) ) : array(); }; // This list is carried forward into the next save, so when a cache copy disagrees with the row // the row is the truth. get_option() reads first, so a failed read's notoptions entry is seen. // A save refuses outright when the row cannot be read (aafm_set_enabled_abilities()). $stored = get_option( 'aafm_enabled_abilities', array() ); $row = aafm_policy_row_if_stale( 'aafm_enabled_abilities' ); if ( null !== $row ) { return $row['ok'] ? $normalize( $row['value'] ) : array(); } return $normalize( $stored ); } /** * Persist the enabled-abilities option: strip any name a floor refuses first, and write an * ability_enable_blocked audit row for every one that was actually attempted. * * This is the single choke point every caller that changes aafm_enabled_abilities must go * through - update_option() is never called on this option anywhere else, and no future caller * should add a second one. The registration-time floor (aafm_get_enabled_abilities()) already * keeps a locked ability out of tools/list, so a locked name reaching the option is inert either * way; this exists because the UI is not a trustworthy authorization boundary. "A locked ability * has no rendered checkbox, so it can never appear in $_POST" was the exact assumption that * shipped the bug this guards against - the Integrations tab's renderer skipped its lock branch * and posted one anyway, and it was saved and logged as a genuine enable. Strip and log here, not * only at the one call site that happened to notice, so neither gap can come back through a * caller that has not been written yet. * * A name already present in the option before this call is the operator's preserved choice, not * a new attempt: aafm_resolve_scoped_enabled_input() and aafm_sanitize_enabled_input() both carry * a previously-stored locked name forward (its absence from a POST is the lock hiding the * control, not intent to disable it), and this function keeps it stored. The registration floor * (aafm_get_enabled_abilities()) is what keeps a locked name out of tools/list, so a stored * locked name is inert while locked - and unlocking the category restores exactly the selection * that was ticked before, which is the floor's stated promise. Stripping stored names here was a bug: * re-locking the category wiped the operator's selection on the very next save. Only a * locked name that was NOT already stored is stripped (and logged): it cannot have come from the * screen, since the screen rendered no control for it. * * The optional $persisted by-reference parameter carries out aafm_update_option_verified()'s * write-actually-took boolean, so a caller that needs to know whether the write itself persisted - * not just the intended set this function always returns - can get at it without a second read. * It is a reference parameter rather than a change to the return shape (an array/bool tuple, or a * result object) on purpose: this function's return value is already depended on directly as the * persisted set by every existing caller and by tests across the suite (HighRiskSaveGuardTest, * ReadOnlyModeTest among them), and changing that shape would have broken every one of them for a * concern only two callers actually need (aafm_save_abilities_section(), * aafm_quickconnect_apply_abilities()). A caller that does not need it simply omits the argument. * * @param array $enabled Ability names to persist. * @param bool|null $persisted Out param: true when the write verified as actually stored. * @param-out bool $persisted * @return array The names actually written (newly attempted locked names removed). */ function aafm_set_enabled_abilities( array $enabled, ?bool &$persisted = null ): array { // The stored list cannot be read this request, so the carry-forward is unknown: write nothing. $row = aafm_policy_row_if_stale( 'aafm_enabled_abilities' ); if ( null !== $row && ! $row['ok'] ) { $persisted = false; return $enabled; } $before = aafm_get_stored_enabled_abilities_raw(); $locked = array_values( array_filter( $enabled, 'aafm_ability_is_locked' ) ); $blocked = array_values( array_diff( $locked, $before ) ); $clean = array_values( array_diff( $enabled, $blocked ) ); // The read-only floor applies the same preserve-stored rule by risk class rather than by // name: an already-stored write is carried forward untouched (there would be nothing to // restore when the mode goes off otherwise), and only a write that was NOT already stored is // refused, since the screen rendered no control it could have come from. if ( aafm_read_only_mode() ) { $refused = array(); foreach ( $clean as $name ) { if ( ! aafm_ability_is_read( $name ) && ! in_array( $name, $before, true ) ) { $refused[] = $name; } } if ( ! empty( $refused ) ) { $clean = array_values( array_diff( $clean, $refused ) ); $blocked = array_values( array_merge( $blocked, $refused ) ); } } // Verified, not a bare update_option(): a persistent object cache that still disagrees with the // database can make a plain write silently no-op (aafm_update_option_verified()'s docblock). // This function's return type is the single choke point every caller already depends on for // diff logging and for what it reports as the persisted set, so a failed write is not raised as // an exception here; a caller that needs to know whether the database actually agreed passes // $persisted by reference rather than being handed a silently-optimistic true. $persisted = aafm_update_option_verified( 'aafm_enabled_abilities', $clean ); if ( ! empty( $blocked ) ) { aafm_log_blocked_ability_enables( $blocked ); } return $clean; } /** * Sanitize posted ability toggles down to known registry keys. * * The result is intersected with the live registry, so a stale, unknown, or smuggled * key can never enable anything - only abilities that actually exist are honored. * * An ability whose host integration is currently inactive is host-gated out of the live * registry, so its checkbox never rendered and it can never appear in $posted - a plain * intersect would silently wipe it on ANY save, not just an Integrations-tab one (M9). It is * unioned back in when it is still a real ability (present in the full, host-independent * registry) that was previously enabled, mirroring the orphan handling in * aafm_ajax_save_bridged_abilities(). * * A previously-enabled write that read-only mode is currently holding down is carried forward for * the same reason: it rendered no checkbox either, so a plain intersect would wipe the operator's * whole write selection on the first save made while the mode is on. * * @param array $posted The $_POST payload, already unslashed by the caller. * @return array */ function aafm_sanitize_enabled_input( array $posted ): array { $known = array_keys( aafm_get_abilities_registry() ); $enabled = array(); if ( isset( $posted['aafm_abilities'] ) && is_array( $posted['aafm_abilities'] ) ) { foreach ( $posted['aafm_abilities'] as $name ) { $enabled[] = sanitize_text_field( (string) $name ); } } $enabled = array_values( array_intersect( $enabled, $known ) ); $registry_full = aafm_get_abilities_registry_full(); $carried = array(); foreach ( aafm_get_stored_enabled_abilities_raw() as $name ) { if ( ! in_array( $name, $known, true ) && isset( $registry_full[ $name ] ) ) { $carried[] = $name; continue; } // A write held down by read-only mode rendered no checkbox, so it cannot appear in the POST // and its absence is not the operator turning it off. The Abilities tab posts an unscoped, // full-replace list, so without this every enabled write would be wiped from the option on // the first save made while the mode is on, and turning the mode back off would hand back a // blank slate instead of the selection it promised to restore. // // Both lock reasons are carried. A high-risk-locked name renders no checkbox // either, so its absence from a full-replace POST is the lock speaking, not the operator // turning it off - and aafm_set_enabled_abilities() keeps a previously-stored locked name // on purpose, so dropping it here would wipe the selection the unlock is supposed to // restore. if ( isset( $registry_full[ $name ] ) && null !== aafm_ability_lock_reason( $name ) ) { $carried[] = $name; } } return array_values( array_unique( array_merge( $enabled, $carried ) ) ); } /** * Resolve the final enabled-abilities list for a scoped save. * * A scoped form (the Integrations tab) only renders toggles for the subjects it owns, so it must * NOT replace the whole enabled option. Instead of trusting client-side hidden inputs to carry the * off-tab abilities forward, this merges server-side: every persisted ability whose subject is * OUTSIDE the posted scope is preserved verbatim, and only the in-scope abilities are taken from * the POST. A tampered or missing hidden input for an off-tab ability therefore cannot flip it. * * When no scope is posted (the Abilities tab), the posted list replaces the option in full - the * unchanged legacy behavior. * * @param array $posted The $_POST payload, already unslashed by the caller. * @return array The enabled-abilities list to persist. */ function aafm_resolve_scoped_enabled_input( array $posted ): array { $posted_enabled = aafm_sanitize_enabled_input( $posted ); // No scope marker: full replace (Abilities tab). if ( ! isset( $posted['aafm_scope'] ) || ! is_array( $posted['aafm_scope'] ) ) { return $posted_enabled; } $scope = array_map( static fn( $s ): string => sanitize_key( (string) $s ), $posted['aafm_scope'] ); $registry = aafm_get_abilities_registry(); $registry_full = aafm_get_abilities_registry_full(); $known = array_keys( $registry ); // Persisted abilities OUTSIDE the posted scope are kept from the server, not the POST. Look // up "is this real" and "which subject" against the FULL, host-independent registry rather // than the live one: an ability whose host integration is currently inactive is host-gated // out of the live registry too, so a plain live-registry check would drop it exactly like // M9's Abilities-tab bug - it can never have been intentionally toggled off since its // checkbox never rendered, so it is always preserved rather than scope-checked. // // A locked ability is the same story from the other direction: a floor takes its checkbox away // while the ability itself is still sitting in the option, so its absence from the POST is the // lock speaking, not the operator. Preserve it too, or the first save of the section it lives in // discards a choice the operator never got the chance to restate, and lifting the floor later // offers a blank slate instead of what was there. Both floors are read through the one lock // predicate, so neither can be preserved while the other is quietly dropped. $preserved = array(); foreach ( aafm_get_stored_enabled_abilities_raw() as $name ) { if ( ! isset( $registry_full[ $name ] ) ) { continue; // Drop truly stale/removed abilities. } if ( ! in_array( $name, $known, true ) ) { $preserved[] = $name; // Inactive-host ability: always preserved. continue; } if ( null !== aafm_ability_lock_reason( $name ) ) { $preserved[] = $name; // Locked: its checkbox never rendered, so its absence is not intent. continue; } $subject = (string) ( $registry[ $name ]['subject'] ?? '' ); if ( ! in_array( $subject, $scope, true ) ) { $preserved[] = $name; } } // In-scope abilities come from the POST (only those whose subject is in the scope). $in_scope = array(); foreach ( $posted_enabled as $name ) { $subject = (string) ( $registry[ $name ]['subject'] ?? '' ); if ( in_array( $subject, $scope, true ) ) { $in_scope[] = $name; } } return array_values( array_unique( array_merge( $preserved, $in_scope ) ) ); } /** * The result of a section that did not write: ok false, the operator-facing message, no data. * * @param string $message Message shown next to the section. * @return array{ok:bool,message:string,data:array} */ function aafm_section_failed( string $message ): array { return array( 'ok' => false, 'message' => $message, 'data' => array(), ); } /** * The result of a section that was left unwritten because it would add entries (see * aafm_ajax_save_abilities_page()). The held flag lets the caller tell it from a failed write. * * @param string $label Human name of the section, already translated. * @return array{ok:bool,message:string,data:array,held:bool} */ function aafm_section_held( string $label ): array { return array_merge( aafm_section_failed( sprintf( /* translators: %s: the name of the section that was not saved, for example "Enabled abilities". */ __( '%s was not saved because another section on this page failed. Fix that section and save again.', 'agent-abilities-for-mcp' ), $label ) ), array( 'held' => true ) ); } /** * Answer an AJAX save with one section's result, in the shape aafm_save_abilities has always * used: the operator message on failure, the section's payload on success. * * @param array{ok:bool,message:string,data:array} $result A section result. * @return void */ function aafm_send_section_result( array $result ): void { if ( ! $result['ok'] ) { wp_send_json_error( array( 'message' => $result['message'] ) ); } wp_send_json_success( $result['data'] ); } /** * Save the enabled-abilities toggles. The caller owns the nonce and capability checks. * * The result carries the full persisted enabled list - the server's authoritative answer, not * whatever the client had checked - plus the global enabled-ability total, so the card/section * headers on either tab can be patched to the real count right after save instead of reading * stale until the next page load. * * @param array $posted The $_POST payload, already unslashed by the caller. * @param bool $refuse_additions Write nothing when the posted list adds an ability * (set once an earlier section of the request failed). * @return array{ok:bool,message:string,data:array} */ function aafm_save_abilities_section( array $posted, bool $refuse_additions = false ): array { $label = __( 'Enabled abilities', 'agent-abilities-for-mcp' ); $before = aafm_get_stored_enabled_abilities_raw(); $resolved = aafm_resolve_scoped_enabled_input( $posted ); if ( $refuse_additions && array() !== array_diff( $resolved, $before ) ) { return aafm_section_held( $label ); } $enabled = aafm_set_enabled_abilities( $resolved, $persisted ); // The toggle diff is only ever an accurate record of what actually happened once the write is // known to have persisted - logging it unconditionally would leave success-style rows for a // change a stale persistent object cache silently swallowed (aafm_log_ability_persist_failure()). if ( ! $persisted ) { aafm_log_ability_persist_failure( 'aafm_enabled_abilities', $label ); return aafm_section_failed( aafm_switch_not_persisted_message( $label ) ); } aafm_log_ability_toggle_diff( $before, $enabled ); return array( 'ok' => true, 'message' => '', 'data' => array( 'enabled' => $enabled, 'ability_enabled_total' => aafm_enabled_ability_count(), ), ); } /** * AJAX: save the enabled-abilities toggles. * * The Integrations tab posts here; the Abilities tab runs the same section through aafm_save_abilities_page (same shared option, different * scope). The response carries the full persisted enabled list - the server's authoritative * answer, not whatever the client had checked - plus the global enabled-ability total, so the * card/section headers on either tab can be patched to the real count right after save instead * of reading stale until the next page load. * * @return void */ function aafm_ajax_save_abilities(): void { check_ajax_referer( 'aafm_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) ), 403 ); } aafm_send_section_result( aafm_save_abilities_section( wp_unslash( $_POST ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. } /** * Sanitize posted "exposed content types" down to eligible, opt-in types. * * The post and page types are always-on (forced by aafm_allowed_post_types()), so they are * intentionally dropped here rather than persisted. Every remaining value must clear the * eligibility floor, so attachment, revision, private CPTs, and junk can never be stored. * * @param array $posted The $_POST payload, already unslashed by the caller. * @return list */ function aafm_sanitize_allowed_post_types_input( array $posted ): array { $types = array(); if ( isset( $posted['aafm_post_types'] ) && is_array( $posted['aafm_post_types'] ) ) { foreach ( $posted['aafm_post_types'] as $type ) { $types[] = sanitize_key( (string) $type ); } } $types = array_diff( $types, array( 'post', 'page' ) ); return array_values( array_filter( array_unique( $types ), 'aafm_post_type_is_eligible' ) ); } /** * The stored members of an exposed-list option, read from the database row itself. * * @param string $option Option name. * @return list|null The list (empty when the row is absent or not an array), or null when * the row could not be read. */ function aafm_stored_option_list( string $option ): ?array { $views = aafm_read_option_views( $option ); if ( $views['db_error'] ) { return null; } return ( $views['db_found'] && is_array( $views['db_value'] ) ) ? array_values( array_map( 'strval', $views['db_value'] ) ) : array(); } /** * Write one Activity Log row when an exposed list really changed: its name and how many keys were * added and removed, never the keys. The detail column carries identifier-only notes, so the text * is a fixed template around a translated label and two integers. * * @param string $option Option name the list is stored in. * @param string $label Human name of the list, already translated. * @param array|null $before The list before the write, or null when that read failed (the * change cannot be stated, so no row is written). * @param array $after The list the write certified. * @return bool Whether a row was written. */ function aafm_log_exposed_list_change( string $option, string $label, ?array $before, array $after ): bool { if ( null === $before ) { return false; } $added = count( array_unique( array_diff( $after, $before ) ) ); $removed = count( array_unique( array_diff( $before, $after ) ) ); if ( 0 === $added && 0 === $removed ) { return false; } $user = wp_get_current_user(); aafm_log_activity( array( 'ability' => $option, 'principal_user_id' => (int) $user->ID, 'principal_login' => $user->user_login ? (string) $user->user_login : '', 'status' => 'success', 'event_type' => 'setting_changed', 'detail' => sprintf( /* translators: 1: list name, 2: number of keys added, 3: number of keys removed. */ __( '%1$s changed: %2$d added, %3$d removed', 'agent-abilities-for-mcp' ), $label, $added, $removed ), ) ); return true; } /** * Save the exposed-content-types allowlist. The caller owns the nonce and capability checks. * * @param array $posted The $_POST payload, already unslashed by the caller. * @param bool $refuse_additions Write nothing when the list adds a type, or when * the stored list cannot be read (set once an earlier * section of the request failed). * @return array{ok:bool,message:string,data:array} */ function aafm_save_post_types_section( array $posted, bool $refuse_additions = false ): array { $label = __( 'Exposed content types', 'agent-abilities-for-mcp' ); $types = aafm_sanitize_allowed_post_types_input( $posted ); $before = aafm_stored_option_list( 'aafm_allowed_post_types' ); if ( $refuse_additions && ( null === $before || array() !== array_diff( $types, $before ) ) ) { return aafm_section_held( $label ); } // Verified, not a bare update_option(): this option gates which content types an agent can // even see, so a stale persistent object cache silently keeping the old list live // must be reported as a failed save, not a success. if ( ! aafm_update_option_verified( 'aafm_allowed_post_types', $types ) ) { aafm_log_ability_persist_failure( 'aafm_allowed_post_types', $label ); return aafm_section_failed( aafm_switch_not_persisted_message( $label ) ); } aafm_log_exposed_list_change( 'aafm_allowed_post_types', $label, $before, $types ); return array( 'ok' => true, 'message' => '', 'data' => array( 'post_types' => $types ), ); } /** * Shared line-by-line sanitizer for a posted meta-key textarea: split on newlines, trim, * run through aafm_sanitize_plain_text() (never sanitize_key(), which would strip `*`), * drop empties, optionally drop hard-blocked keys, and de-duplicate. * * $hard_block is null for a deny-list field (denied keys are never filtered - denying an * already hard-blocked key is a harmless no-op, and the deny list must be able to name * anything an admin wants refused). For an allow-list field it is the matching hard-block * checker; $exempt_star controls whether the `*` wildcard sentinel skips that check * entirely (true for the user/term allow-lists, matching their original behavior) or is * still run through it like any other key (false for the post allow-list, its original * behavior) - each caller passes exactly what its own inline code decided. * * @param array $posted Raw $_POST payload (slashes handled by the caller). * @param string $field The $_POST key holding the raw textarea value. * @param callable|null $hard_block Hard-block checker, or null to skip filtering. * @param bool $exempt_star Whether `*` bypasses $hard_block. * @return list */ function aafm_sanitize_meta_keys_lines( array $posted, string $field, ?callable $hard_block, bool $exempt_star ): array { $raw = isset( $posted[ $field ] ) ? (string) $posted[ $field ] : ''; $keys = array(); foreach ( (array) preg_split( '/\r\n|\r|\n/', $raw ) as $line ) { $key = aafm_sanitize_plain_text( trim( (string) $line ) ); if ( '' === $key ) { continue; } if ( null !== $hard_block && ! ( $exempt_star && '*' === $key ) && $hard_block( $key ) ) { continue; } $keys[] = $key; } return array_values( array_unique( $keys ) ); } /** * Sanitize the posted "exposed meta keys" textarea into a clean, de-duplicated allowlist. * * Splits on newlines, trims each line (meta keys are case-sensitive, so case is preserved; * only surrounding whitespace and control chars are stripped via aafm_sanitize_plain_text), drops * empties, drops any hard-blocked key so a blocked key can never even be stored, de-duplicates, * and re-indexes. The read path (aafm_allowed_meta_keys) re-floors anyway; this is best-effort. * * @param array $posted Raw $_POST payload (slashes handled here). * @return list */ function aafm_sanitize_allowed_meta_keys_input( array $posted ): array { return aafm_sanitize_meta_keys_lines( $posted, 'aafm_meta_keys', 'aafm_hard_blocked_meta_key', false ); } /** * Parse the denied-post-meta textarea into a clean list. * * Mirrors aafm_sanitize_allowed_meta_keys_input() but for the DENY list: it KEEPS the `*` * wildcard sentinel (deny-all) and does NOT strip hard-blocked keys - denying an already * hard-blocked key is a harmless no-op, and the deny list must be able to name anything an * admin wants refused. Splits on newlines, trims, aafm_sanitize_plain_text (never sanitize_key, * which would strip `*`), drops empties, and de-duplicates. * * @param array $posted Raw $_POST payload (slashes handled by the caller). * @return list */ function aafm_sanitize_denied_meta_keys_input( array $posted ): array { return aafm_sanitize_meta_keys_lines( $posted, 'aafm_deny_meta_keys', null, false ); } /** * Parse the exposed-user-meta textarea into a clean list. * * Mirrors the allow-list sanitizer but for user meta and KEEPS the `*` wildcard. Splits on * newlines, trims, aafm_sanitize_plain_text (never sanitize_key, which would strip `*`), drops * empties and any hard-blocked user key (best-effort - aafm_allowed_user_meta_keys() re-floors * anyway), and de-duplicates. * * @param array $posted Raw $_POST payload (slashes handled by the caller). * @return list */ function aafm_sanitize_exposed_user_meta_keys_input( array $posted ): array { return aafm_sanitize_meta_keys_lines( $posted, 'aafm_exposed_user_meta_keys', 'aafm_hard_blocked_user_meta_key', true ); } /** * Parse the denied-user-meta textarea into a clean list. * * Like aafm_sanitize_denied_meta_keys_input() but user-scoped: KEEPS `*` (deny-all) and does * NOT strip hard-blocked keys. Splits on newlines, trims, aafm_sanitize_plain_text, drops empties, * de-duplicates. * * @param array $posted Raw $_POST payload (slashes handled by the caller). * @return list */ function aafm_sanitize_denied_user_meta_keys_input( array $posted ): array { return aafm_sanitize_meta_keys_lines( $posted, 'aafm_denied_user_meta_keys', null, false ); } /** * Parse the exposed-term-meta textarea into a clean list. * * Mirrors aafm_sanitize_exposed_user_meta_keys_input() but term-scoped (the term/post-meta * hard-block applies). KEEPS the `*` wildcard, splits on newlines, trims, aafm_sanitize_plain_text * (never sanitize_key, which would strip `*`), drops empties and any hard-blocked key * (best-effort - aafm_allowed_term_meta_keys() re-floors anyway), and de-duplicates. * * @param array $posted Raw $_POST payload (slashes handled by the caller). * @return list */ function aafm_sanitize_exposed_term_meta_keys_input( array $posted ): array { return aafm_sanitize_meta_keys_lines( $posted, 'aafm_exposed_term_meta_keys', 'aafm_hard_blocked_meta_key', true ); } /** * Parse the denied-term-meta textarea into a clean list. * * Like aafm_sanitize_denied_user_meta_keys_input() but term-scoped: KEEPS `*` (deny-all) and * does NOT strip hard-blocked keys. Splits on newlines, trims, aafm_sanitize_plain_text, drops * empties, de-duplicates. * * @param array $posted Raw $_POST payload (slashes handled by the caller). * @return list */ function aafm_sanitize_denied_term_meta_keys_input( array $posted ): array { return aafm_sanitize_meta_keys_lines( $posted, 'aafm_denied_term_meta_keys', null, false ); } /** * Sample up to 50 distinct, non-hard-blocked meta keys present on posts of the allowlisted * types - the "Detected on your exposed types" chip source for the selector. * * One read-only, prepared query (dynamic IN of bound %s placeholders for the exposed types), * scoped to the most recent posts of those types via a bounded derived table so the postmeta * scan stays bounded on a very large site, filtered against the hard-block, sliced to 50, cached * 5 minutes in a best-effort transient. Purely cosmetic: the cache is advisory and the allowlist * gate never trusts this list. * * @return list */ function aafm_detected_meta_keys(): array { $cached = get_transient( 'aafm_detected_meta_keys' ); if ( is_array( $cached ) ) { return array_values( array_map( 'strval', $cached ) ); } global $wpdb; $types = aafm_allowed_post_types(); if ( empty( $types ) ) { return array(); } $ph = implode( ',', array_fill( 0, count( $types ), '%s' ) ); // $ph is a list of %s placeholders, the type values are bound via prepare() below. // Join postmeta against a bounded derived table of the most recent posts of the exposed types // rather than every such post. Without the bound, DISTINCT + ORDER BY on postmeta materialise // the whole set before the outer LIMIT can trim it, so the LIMIT never bounds the work and the // scan is proportional to the entire postmeta table on a very large site. The derived table's // own LIMIT caps that scan; on any normal site (far fewer than 5000 posts of the exposed types) // the result is identical, matching the "sample" this list has always been. A derived table is // used, not an IN (... LIMIT), because a LIMIT inside an IN subquery is not portable across the // supported MySQL/MariaDB versions. // // This used to be a bare $wpdb->get_col(), which hands back the PREVIOUS query's column when // this one fails. Routed through aafm_wpdb_col(): a failed read reports no keys rather than a // stale, unrelated set - this list is only ever an admin-side suggestion an operator narrows // from, never a gate, so the safe direction on failure is the same "suggest nothing" an // ineligible post-type list already returns above. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $view = aafm_wpdb_col( $wpdb->prepare( "SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm INNER JOIN ( SELECT ID FROM {$wpdb->posts} WHERE post_type IN ($ph) ORDER BY ID DESC LIMIT 5000 ) p ON p.ID = pm.post_id ORDER BY pm.meta_key ASC LIMIT 200", $types ) ); $rows = $view['ok'] ? $view['value'] : array(); $keys = array_map( 'strval', (array) $rows ); $keys = array_values( array_filter( $keys, static fn( string $k ): bool => ! aafm_hard_blocked_meta_key( $k ) ) ); $keys = array_slice( $keys, 0, 50 ); set_transient( 'aafm_detected_meta_keys', $keys, 5 * MINUTE_IN_SECONDS ); return $keys; } /** * Write a paired exposed/deny option pair through the three-stage sequence that keeps every * intermediate state at least as strict as the state before the request, even when the same * request narrows BOTH lists at once. * * The earlier fix (deny always written before exposed) is not direction-aware: it only protects * a request that ADDS to deny or REMOVES from exposed. A request that REMOVES a key from deny * and REMOVES it from exposed in the same submission (the bundled UI can post both fields * together) is a live counter-example: if the exposed write fails after the deny write already * dropped the key, the key ends up off deny and still on the old exposed list - reachable, when * both edits meant to make it unreachable. The three stages below stay deny-monotonic at every * point in between: * * 1. deny := union(old deny, new deny) - never narrower than either list, so nothing either * side wanted denied is ever briefly undenied. * 2. exposed := new exposed - if this fails, deny is still the stage-1 union, which already * denies everything the old or the new state wanted denied. * 3. deny := new deny - narrows deny down to what was actually requested; if this fails, deny * simply stays at the stage-1 union instead of reaching its final, narrower value. * * The "old deny" half of the union is read here, authoritatively, from the database row itself * rather than accepted as a caller-supplied snapshot. The getters * (aafm_denied_meta_keys() and its user/term siblings) both trust a cache-backed get_option() * and strip the `*` deny-all sentinel for display purposes, so a caller-supplied snapshot could * either be stale (a persistent cache still answering for a since-changed row) or silently drop * a live deny-all - either way the stage-1 union would be built from a narrower list than what * is actually stored, and stage 1 could then certify a union that is itself wider than the * pre-request state. Reading the raw row directly, sentinel included, keeps the union at least * as strict as whatever is really in the database right now. * * The "old deny" read above must not treat a failed * read (db_error) the same as a genuinely empty/absent row - that would build the stage-1 union * from an empty old-deny list, silently dropping whatever was actually denied before this * request, the same direction of mistake the OAuth migration reads had. This is a WRITE path, not * a live authorization read, but the fail-safe direction is the same reasoning as * aafm_ability_allowed_for_principal() (includes/allowlist.php): when the existing state cannot be * certified, refuse rather than proceed on an assumed-empty state. * * @param string $deny_option Deny-list option name. * @param string $exposed_option Exposed-list option name. * @param array $new_deny Requested deny list. * @param array $new_exposed Requested exposed list. * @return int 0 on full success; 1, 2, or 3 naming the stage that failed to certify. */ function aafm_paired_meta_write_three_stage( string $deny_option, string $exposed_option, array $new_deny, array $new_exposed ): int { $views = aafm_read_option_views( $deny_option ); if ( $views['db_error'] ) { return 1; } $old_deny = ( $views['db_found'] && is_array( $views['db_value'] ) ) ? array_map( 'strval', $views['db_value'] ) : array(); $union = array_values( array_unique( array_merge( $old_deny, $new_deny ) ) ); if ( ! aafm_update_option_verified( $deny_option, $union ) ) { return 1; } if ( ! aafm_update_option_verified( $exposed_option, $new_exposed ) ) { return 2; } if ( ! aafm_update_option_verified( $deny_option, $new_deny ) ) { return 3; } return 0; } /** * Save one exposed/deny meta-key pair (post, user or term meta) through the three-stage write. * The caller owns the nonce and capability checks. * * The exposed list and the deny list are persisted together so the UI can never report "Saved" * while one of the two writes silently failed. A deny field missing from $posted reads as an * empty list, which clears it; aafm_ajax_save_abilities_page() refuses that shape before it gets * here. * * @param string $kind 'post', 'user' or 'term'. * @param array $posted The $_POST payload, already unslashed by the caller. * @param bool $refuse_additions Write nothing when the pair would widen: the exposed list * gains a key (unless the stored list is already `*`), the * deny list loses one (unless the request is `*`), or * either stored list cannot be read. * @return array{ok:bool,message:string,data:array} */ function aafm_save_meta_pair_section( string $kind, array $posted, bool $refuse_additions = false ): array { $kinds = array( 'post' => array( 'exposed_fn' => 'aafm_sanitize_allowed_meta_keys_input', 'denied_fn' => 'aafm_sanitize_denied_meta_keys_input', 'exposed_opt' => 'aafm_allowed_meta_keys', 'denied_opt' => 'aafm_denied_meta_keys', 'exposed_label' => __( 'Exposed post meta keys', 'agent-abilities-for-mcp' ), 'denied_label' => __( 'Denied post meta keys', 'agent-abilities-for-mcp' ), 'exposed_key' => 'meta_keys', 'denied_key' => 'deny_meta_keys', ), 'user' => array( 'exposed_fn' => 'aafm_sanitize_exposed_user_meta_keys_input', 'denied_fn' => 'aafm_sanitize_denied_user_meta_keys_input', 'exposed_opt' => 'aafm_exposed_user_meta_keys', 'denied_opt' => 'aafm_denied_user_meta_keys', 'exposed_label' => __( 'Exposed user meta keys', 'agent-abilities-for-mcp' ), 'denied_label' => __( 'Denied user meta keys', 'agent-abilities-for-mcp' ), 'exposed_key' => 'exposed_user_meta_keys', 'denied_key' => 'denied_user_meta_keys', ), 'term' => array( 'exposed_fn' => 'aafm_sanitize_exposed_term_meta_keys_input', 'denied_fn' => 'aafm_sanitize_denied_term_meta_keys_input', 'exposed_opt' => 'aafm_exposed_term_meta_keys', 'denied_opt' => 'aafm_denied_term_meta_keys', 'exposed_label' => __( 'Exposed term meta keys', 'agent-abilities-for-mcp' ), 'denied_label' => __( 'Denied term meta keys', 'agent-abilities-for-mcp' ), 'exposed_key' => 'exposed_term_meta_keys', 'denied_key' => 'denied_term_meta_keys', ), ); $c = $kinds[ $kind ]; $exposed = $c['exposed_fn']( $posted ); $denied = $c['denied_fn']( $posted ); $before = aafm_stored_option_list( $c['exposed_opt'] ); if ( $refuse_additions ) { $before_denied = aafm_stored_option_list( $c['denied_opt'] ); // A stored exposed `*` already covers every key the request names, and a requested deny `*` // already refuses every key the request drops, so neither set difference is a widening then. if ( null === $before || null === $before_denied || ( ! in_array( '*', $before, true ) && array() !== array_diff( $exposed, $before ) ) || ( ! in_array( '*', $denied, true ) && array() !== array_diff( $before_denied, $denied ) ) ) { return aafm_section_held( $c['exposed_label'] ); } } // Verified, not a bare update_option(): these two options gate which meta an agent can read or // write, so a stale persistent object cache silently keeping the old list live // must be reported as a failed save, not a success. The three-stage write order keeps every // intermediate state at least as strict as before this request, even when it narrows both // lists at once. $stage = aafm_paired_meta_write_three_stage( $c['denied_opt'], $c['exposed_opt'], $denied, $exposed ); if ( 1 === $stage ) { aafm_log_ability_persist_failure( $c['denied_opt'], $c['denied_label'] ); return aafm_section_failed( aafm_switch_not_persisted_message( $c['denied_label'] ) ); } if ( 2 === $stage ) { aafm_log_ability_persist_failure( $c['exposed_opt'], $c['exposed_label'] ); return aafm_section_failed( aafm_paired_write_partial_failure_message( $c['denied_label'], $c['exposed_label'] ) ); } // The exposed list landed in stages 0 and 3. aafm_log_exposed_list_change( $c['exposed_opt'], $c['exposed_label'], $before, $exposed ); if ( 3 === $stage ) { aafm_log_ability_persist_failure( $c['denied_opt'], $c['denied_label'] ); return aafm_section_failed( aafm_paired_write_partial_failure_message( $c['exposed_label'], $c['denied_label'] ) ); } if ( 'post' === $kind ) { delete_transient( 'aafm_detected_meta_keys' ); } return array( 'ok' => true, 'message' => '', 'data' => array( $c['exposed_key'] => $exposed, $c['denied_key'] => $denied, ), ); } /** * AJAX: save every section of the Abilities tab the request names, in one request. * * The request lists the sections it carries in aafm_sections[] and each one is written through its * own section function. A section that is not named is never read * or written. A named section whose fields are missing or the wrong type is refused rather than * read as empty (a missing deny field would otherwise clear the deny list); an empty list is sent * as a textarea's empty string or as a list field's single empty entry. * * Sections that only narrow (remove an exposed key, a content type or an ability, add a deny * entry, or change nothing) are written first, in the order of the table below. Sections that * would widen (add an exposed key, a content type or an ability, or drop a deny entry) are written * after them in the same order, the meta pairs (gates) before content types and enabled abilities * (doors), and only while every section so far has saved: once any section has not saved, every * widening section left is held back (aafm_section_held()). A widening is never written over a * narrowing that did not land. A section that both widens and narrows is held whole. A failed * section does not stop the narrowing ones. The answer has one result per named section, in table * order. * * @return void */ function aafm_ajax_save_abilities_page(): void { check_ajax_referer( 'aafm_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) ), 403 ); } $posted = wp_unslash( $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. $named = ( isset( $posted['aafm_sections'] ) && is_array( $posted['aafm_sections'] ) ) ? array_map( 'sanitize_key', $posted['aafm_sections'] ) : array(); $table = array( 'meta_keys' => array( array( 'aafm_meta_keys' => 'is_string', 'aafm_deny_meta_keys' => 'is_string', ), static fn( array $p, bool $hold ): array => aafm_save_meta_pair_section( 'post', $p, $hold ), ), 'user_meta_keys' => array( array( 'aafm_exposed_user_meta_keys' => 'is_string', 'aafm_denied_user_meta_keys' => 'is_string', ), static fn( array $p, bool $hold ): array => aafm_save_meta_pair_section( 'user', $p, $hold ), ), 'term_meta_keys' => array( array( 'aafm_exposed_term_meta_keys' => 'is_string', 'aafm_denied_term_meta_keys' => 'is_string', ), static fn( array $p, bool $hold ): array => aafm_save_meta_pair_section( 'term', $p, $hold ), ), 'post_types' => array( array( 'aafm_post_types' => 'is_array' ), 'aafm_save_post_types_section', ), 'abilities' => array( array( 'aafm_abilities' => 'is_array', 'aafm_scope' => 'is_array', ), 'aafm_save_abilities_section', ), ); $results = array(); $run = array(); $failed = false; foreach ( $table as $key => $section ) { if ( ! in_array( $key, $named, true ) ) { continue; } $complete = true; foreach ( $section[0] as $field => $is_type ) { $complete = $complete && isset( $posted[ $field ] ) && $is_type( $posted[ $field ] ); } if ( $complete ) { $run[ $key ] = $section[1]; continue; } $results[ $key ] = aafm_section_failed( __( 'This section was not saved because the request was incomplete. Reload the page and try again.', 'agent-abilities-for-mcp' ) ); $failed = true; } // Pass 1: every section that does not widen anything is written, in table order. A section that // would widen (add an ability, a content type or an exposed key, or drop a deny entry) comes // back held and writes nothing, because a narrowing in another section may be what makes the // widening safe and that narrowing has not landed yet. A lone section has nothing to wait for // and is written as it always was, unless a section that was never written has already failed. $defer = count( $run ) > 1; foreach ( $run as $key => $section_fn ) { $results[ $key ] = $section_fn( $posted, $defer || $failed ); $failed = $failed || ( ! $results[ $key ]['ok'] && empty( $results[ $key ]['held'] ) ); } // Pass 2: the widening sections, in table order (gates before doors), but only while every // section so far has saved. The first one that does not save holds the rest. foreach ( $run as $key => $section_fn ) { if ( $failed || empty( $results[ $key ]['held'] ) ) { continue; } $results[ $key ] = $section_fn( $posted, false ); $failed = ! $results[ $key ]['ok']; } $sections = array(); foreach ( array_keys( $table ) as $key ) { if ( isset( $results[ $key ] ) ) { $sections[ $key ] = array_diff_key( $results[ $key ], array( 'held' => true ) ); } } if ( $failed ) { wp_send_json_error( array( 'sections' => $sections ) ); } wp_send_json_success( array( 'sections' => $sections ) ); } /** * Contribute suggested privacy-policy text describing what an exposed content type leaks. * * Covers every PII surface the plugin can expose once an operator opts into it: content-type * fields and their meta keys (the original scope), user data if user abilities are enabled, * WooCommerce customer/order data if that integration is enabled (billing/shipping address, * phone, and order history - see includes/abilities/woocommerce/customers.php), and ACF field * values if that integration is enabled (which read/write directly and bypass the post-meta * allowlist entirely - see includes/abilities/acf-integration.php). * * @return void */ function aafm_register_privacy_policy_content(): void { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } $content = wp_kses_post( '

' . __( 'When an administrator exposes a content type to AI agents through Agent Abilities for MCP, an authenticated agent can read that type\'s title, slug, excerpt, status, permalink, publish/modified dates, and author id. If an administrator also exposes specific meta keys, an agent can read and change those keys\' values on any post it is allowed to edit. Protected keys (those prefixed with an underscore) and authentication-related keys can never be exposed. Only expose content types and meta keys whose values do not hold personal data.', 'agent-abilities-for-mcp' ) . '

' . '

' . __( 'If user abilities are enabled, an agent can read a user\'s display name, email, roles, and post count, plus any exposed user meta. If the WooCommerce integration is enabled, an agent can read and, depending on which abilities are turned on, write customer and order data - including full billing and shipping addresses, phone number, and order/purchase history. If the ACF (or SCF) integration is enabled, an agent can read and write ACF field values directly; ACF fields are a separate surface from the meta-key allowlist above and are not filtered by it.', 'agent-abilities-for-mcp' ) . '

' . '

' . aafm_privacy_policy_change_history_text() . '

' ); wp_add_privacy_policy_content( __( 'Agent Abilities for MCP', 'agent-abilities-for-mcp' ), $content ); } /** * AJAX: clear the activity log. * * Clearing is itself a security-relevant action - a manage_options user (or an attacker who * has reached that capability) can otherwise wipe the audit trail without leaving any trace * that it happened. Write one final row into the freshly emptied log recording who cleared it * and when, so the log is never completely silent about its own history (L4). * * @return void */ function aafm_ajax_clear_log(): void { check_ajax_referer( 'aafm_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) ), 403 ); } if ( ! aafm_clear_activity_log() ) { wp_send_json_error( array( 'message' => __( 'Could not clear the activity log. Please try again.', 'agent-abilities-for-mcp' ) ) ); } if ( ! aafm_log_activity_cleared_marker() ) { wp_send_json_error( array( 'message' => __( 'The activity log was cleared, but the clear could not be recorded. Please try again.', 'agent-abilities-for-mcp' ) ) ); } // The emptied view shows "entries, keeping ..." under its zero count; the server words it. wp_send_json_success( array( 'count_note' => aafm_activity_count_note( 0, aafm_log_retention_days() ) ) ); } /** * Write a tamper-evident marker row recording that the activity log was cleared. * * Shared by the direct "Clear log" action and aafm_reset_plugin() (which also empties the * log), so either path leaves the same trace: who cleared it and when. The row says what it is * in its event_type, which is the column that exists for that. It also keeps a synthetic * ability name that can never collide with a real ability (the ability column is free text, * not registry-validated) - not because that is still how the row is identified, but because * markers written before schema v5 have no event_type, and keeping the name means an operator's * old and new markers still read as the same event. * * @return bool True when the marker row was actually inserted: a failed * insert here would otherwise leave the freshly emptied log with no tamper * evidence at all while the caller still reported success. */ function aafm_log_activity_cleared_marker(): bool { $user = wp_get_current_user(); return aafm_log_activity( array( 'ability' => 'aafm/activity-log-cleared', 'principal_user_id' => (int) $user->ID, 'principal_login' => $user->user_login ? (string) $user->user_login : '', 'status' => 'success', 'event_type' => 'log_cleared', 'detail' => __( 'Activity log cleared', 'agent-abilities-for-mcp' ), ) ) > 0; } /** * Write one audit row per ability whose enabled state changed. * * Toggling an ability is what makes it reachable at all, and until now that moment left no trace: * only calls were logged, never the enable. After an incident the question is usually "when did * this become reachable, and who made it so", and this is the row that answers it. Both directions * are recorded, because an ability being switched off is equally part of the timeline. * * Only changed abilities produce a row, so a re-save with no edits writes nothing and a bulk * "enable all" writes one row per ability it actually turned on. * * @param array $before Enabled ability names before the save. * @param array $after Enabled ability names after the save. * @return int Number of rows written. */ function aafm_log_ability_toggle_diff( array $before, array $after ): int { $enabled = array_diff( $after, $before ); $disabled = array_diff( $before, $after ); if ( empty( $enabled ) && empty( $disabled ) ) { return 0; } $user = wp_get_current_user(); $written = 0; $changes = array( 'ability_enabled' => $enabled, 'ability_disabled' => $disabled, ); foreach ( $changes as $event => $names ) { foreach ( $names as $name ) { $detail = 'ability_enabled' === $event /* translators: %s: ability name. */ ? sprintf( __( 'Enabled %s', 'agent-abilities-for-mcp' ), (string) $name ) /* translators: %s: ability name. */ : sprintf( __( 'Disabled %s', 'agent-abilities-for-mcp' ), (string) $name ); aafm_log_activity( array( 'ability' => (string) $name, 'principal_user_id' => (int) $user->ID, 'principal_login' => $user->user_login ? (string) $user->user_login : '', 'status' => 'success', 'event_type' => $event, 'detail' => $detail, ) ); ++$written; } } return $written; } /** * Write one audit row per locked ability a save attempted to enable. * * Called by aafm_set_enabled_abilities() with the locked names it just stripped and that were * genuinely new (not already sitting in the option). A locked ability has no rendered checkbox * (see aafm_render_ability_row() and aafm_render_integration_ability_row()), so its name arriving * here means the request bypassed the UI - a forged POST, a stale cached form, or a bug like the * one this replaces: the Integrations tab used to skip the lock branch, save the name, and log it * as a real "Enabled" event even though it was never actually reachable. A refused attempt is * still security signal, so it is recorded explicitly rather than left silent. * * The detail names the ACTUAL cause per name. This row exists to record the forged/stale * POST event, so a hardcoded "(high-risk locked)" was a false statement whenever the real refusal * came from read-only mode. When no reason is supplied it is resolved per name through * aafm_ability_lock_reason(), whose read-only-first precedence already answers "which switch is * actually holding this down". Native aafm/* names only on that path - a bridged caller passes * its own reason explicitly, because aafm_ability_lock_reason() must never see a bridged slug * (see its docblock). * * @param array $blocked Locked ability names a save just attempted to enable. * @param string|null $reason Lock reason for every name ('read_only' or 'high_risk'), or * null to resolve per name via aafm_ability_lock_reason(). * @return int Number of rows written. */ function aafm_log_blocked_ability_enables( array $blocked, ?string $reason = null ): int { if ( empty( $blocked ) ) { return 0; } $user = wp_get_current_user(); $written = 0; foreach ( $blocked as $name ) { $cause = null === $reason ? aafm_ability_lock_reason( (string) $name ) : $reason; if ( 'read_only' === $cause ) { /* translators: %s: ability name. */ $detail = sprintf( __( 'Blocked %s (read-only mode)', 'agent-abilities-for-mcp' ), (string) $name ); } elseif ( 'high_risk' === $cause ) { /* translators: %s: ability name. */ $detail = sprintf( __( 'Blocked %s (high-risk locked)', 'agent-abilities-for-mcp' ), (string) $name ); } else { // The lock lifted between the strip and this log call, or an unknown reason string: // still record the refusal, just without asserting a cause that no longer holds. /* translators: %s: ability name. */ $detail = sprintf( __( 'Blocked %s (locked)', 'agent-abilities-for-mcp' ), (string) $name ); } aafm_log_activity( array( 'ability' => (string) $name, 'principal_user_id' => (int) $user->ID, 'principal_login' => $user->user_login ? (string) $user->user_login : '', 'status' => 'denied', 'event_type' => 'ability_enable_blocked', 'detail' => $detail, ) ); ++$written; } return $written; } /** * AJAX: return one page of activity rows for a given page number and filter set. * * Server-side paging and filtering so the table never has to load tens of thousands of rows * into the DOM. The request is reduced to the known, valid filters by * aafm_activity_filters_from_request() (status, user, connection, tool, plugin, dates, search, one * row); the page number is clamped to the filtered total. Returns flat row values for the JS * renderer plus the paging state it needs to update the pager, and the 7-day summary when the * request sets with_summary. Read-only - never touches state. * * @return void */ function aafm_ajax_get_log_page(): void { check_ajax_referer( 'aafm_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. $posted = wp_unslash( $_POST ); $filters = aafm_activity_filters_from_request( is_array( $posted ) ? $posted : array() ); $filter = isset( $filters['status'] ) ? (string) $filters['status'] : 'all'; $per_page = aafm_activity_page_size(); $total = aafm_activity_count_matching( $filters ); $total_pages = max( 1, (int) ceil( $total / $per_page ) ); // An array or other non-string value is ignored rather than cast. $page = isset( $posted['page'] ) && is_string( $posted['page'] ) ? absint( $posted['page'] ) : 1; $page = min( max( 1, $page ), $total_pages ); $rows = aafm_query_activity( array_merge( $filters, array( 'per_page' => $per_page, 'page' => $page, ) ) ); $data = array( 'rows' => aafm_activity_rows_data( $rows ), 'page' => $page, 'total_pages' => $total_pages, 'total' => $total, 'count_note' => aafm_activity_count_note( $total, aafm_log_retention_days() ), 'filter' => $filter, 'filtered' => array() !== $filters, ); if ( ! empty( $posted['with_summary'] ) ) { $data['summary'] = aafm_activity_summary_view( aafm_activity_summary() ); } wp_send_json_success( $data ); } /** * The admin_post handler that streams the activity log as a CSV download. * * Not AJAX - a browser navigation to admin-post.php so the response can be a file download rather * than JSON. Nonce and capability are checked the same way the AJAX handlers on this page are, * just via check_admin_referer() (GET-safe) instead of check_ajax_referer(). The actual export is * aafm_export_activity_csv(), kept separate so it is testable without a request. * * @return void */ function aafm_handle_export_activity_log(): void { // Capability first, then nonce - the WordPress convention, and not just style here: with the // nonce checked first, an unauthenticated or under-privileged caller got a nonce failure // ("link expired") instead of an authorization failure, which is the wrong refusal reason to // show them even though aafm_export_activity_csv() was never reachable either way. if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'aafm_admin' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified above. $request = wp_unslash( $_GET ); $filters = aafm_activity_filters_from_request( is_array( $request ) ? $request : array() ); $filename = sanitize_file_name( get_bloginfo( 'name' ) . '-activity-log-' . gmdate( 'Y-m-d' ) . '.csv' ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); aafm_export_activity_csv( $filters ); exit; } /** * Column headers for the activity log table, in render order. * * The single list backing both the server-rendered and the AJAX row shape's expected * column count (see ActivityTabTest), so a future column change cannot silently desync the * header from either row renderer - the HTML row builder and the JS renderer fed by * aafm_activity_rows_data() both have to produce exactly one cell per entry here. * * @return array Translated column labels, in display order. */ function aafm_activity_log_headers(): array { return array( __( 'Time (UTC)', 'agent-abilities-for-mcp' ), __( 'Principal', 'agent-abilities-for-mcp' ), __( 'Connection', 'agent-abilities-for-mcp' ), __( 'Event', 'agent-abilities-for-mcp' ), __( 'Detail', 'agent-abilities-for-mcp' ), __( 'Status', 'agent-abilities-for-mcp' ), __( 'Duration', 'agent-abilities-for-mcp' ), __( 'IP', 'agent-abilities-for-mcp' ), __( 'Arg keys', 'agent-abilities-for-mcp' ), ); } /** * Normalize activity rows to a flat, JSON-safe shape for the client renderer. * * The JS builds each table cell with textContent (never innerHTML), so this exposes plain values, * not markup - including for the detail column's link. `detail` is the raw text; `detail_link` * is null when the detail carries no linkable identifier, or the before/id/url/after parts a * renderer can assemble into an anchor without ever concatenating untrusted text into HTML. Only * the columns the table shows are exposed; the log holds argument KEYS (never values), a * REMOTE_ADDR source IP, and since v5 a short detail string: an allowlisted identifier, a * WP_Error code, or a crash's class name and file:line (aafm_build_activity_detail_from_exception()). * A code returned by a bridged foreign ability is prefixed `foreign:` and kept only when it is * a short key-shaped string, never its message, so there is no free text to strip beyond that. * * @param array> $rows Rows from aafm_query_activity(). * @return array,status:string,variant:string,duration:string,ip:string,arg_keys:string}> */ function aafm_activity_rows_data( array $rows ): array { $names = aafm_activity_connection_names( array_column( $rows, 'client_id' ) ); $out = array(); foreach ( $rows as $row ) { $out[] = aafm_activity_row_view( $row, $names ); } return $out; } /** * The admin tab slugs → labels, shared by menu registration and the page renderer. * * @return array */ function aafm_admin_tabs(): array { return array( 'dashboard' => __( 'Dashboard', 'agent-abilities-for-mcp' ), 'connection' => __( 'Connection', 'agent-abilities-for-mcp' ), 'abilities' => __( 'Abilities', 'agent-abilities-for-mcp' ), 'playbooks' => __( 'Playbooks', 'agent-abilities-for-mcp' ), 'integrations' => __( 'Integrations', 'agent-abilities-for-mcp' ), // Short on purpose. This label renders twice, in the on-page tab strip and in the wp-admin // submenu, and every other entry is one or two words. The page lede carries the explanation // that these are abilities the other plugins registered themselves, rather than ones we // ship for them, which is what separates this tab from Integrations. 'bridge' => __( 'Other plugins', 'agent-abilities-for-mcp' ), 'settings' => __( 'Settings', 'agent-abilities-for-mcp' ), 'activity' => __( 'Activity Log', 'agent-abilities-for-mcp' ), 'changes' => __( 'Changes', 'agent-abilities-for-mcp' ), 'help' => __( 'Help', 'agent-abilities-for-mcp' ), ); } /** * Render the page shell + the active tab. * * @return void */ function aafm_render_admin_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $tabs = aafm_admin_tabs(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab routing, no state change. $active = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( (string) $_GET['tab'] ) ) : 'dashboard'; if ( ! isset( $tabs[ $active ] ) ) { $active = 'dashboard'; } $adapter_version = aafm_loaded_adapter_version(); if ( null !== $adapter_version ) { $pill_class = 'aafm-pill aafm-pill-success'; $pill_label = __( 'Endpoint live', 'agent-abilities-for-mcp' ); } else { $pill_class = 'aafm-pill aafm-pill-warn'; $pill_label = __( 'Adapter not loaded', 'agent-abilities-for-mcp' ); } echo '
'; // Header: title + lede on the left, the status pill on the right (moved out of the h1). echo '
'; echo '

' . esc_html__( 'Agent Abilities for MCP', 'agent-abilities-for-mcp' ) . '

'; echo '

' . esc_html__( 'Give an AI agent scoped, audited access to this site. Nothing is exposed until you turn it on, and every call is logged.', 'agent-abilities-for-mcp' ) . '

'; echo '
'; // Two pills, side by side: the site's exposure posture, then the endpoint state. The posture // pill states the posture ALWAYS, not only when something is restricted, and it renders here in // the page header rather than on the Abilities tab on purpose. That tab lists native abilities // only - no WooCommerce, no bridged - and a global switch sitting on a partial list reads as // scoped to that list, which is the exact mismatch that produced the 1.5.0 lock bug. This band // is the one element on the page whose scope is unambiguously everything. $posture = aafm_exposure_posture(); echo ''; printf( '%2$s', aafm_pause_all() ? '' : ' hidden', esc_html__( 'Access paused', 'agent-abilities-for-mcp' ) ); printf( '%3$s', esc_attr( $posture['variant'] ), esc_attr( $posture['state'] ), esc_html( $posture['label'] ) ); printf( '%2$s', esc_attr( $pill_class ), esc_html( $pill_label ) ); echo ''; echo '
'; // Anchor for core's admin-notice relocation: the

now sits inside // .aafm-page-head, so mark where WordPress should drop notices. echo '
'; aafm_render_undo_notice(); echo ''; switch ( $active ) { case 'connection': aafm_render_connection_tab(); break; case 'abilities': aafm_render_abilities_tab(); break; case 'playbooks': aafm_render_playbooks_tab(); break; case 'integrations': aafm_render_integrations_tab(); break; case 'bridge': aafm_render_bridge_directory(); break; case 'settings': aafm_render_settings_tab(); break; case 'activity': aafm_render_activity_tab(); break; case 'changes': aafm_render_changes_tab(); break; case 'help': aafm_render_help_tab(); break; default: aafm_render_dashboard_tab(); } // First-run onboarding: a self-gated modal that only renders when setup is neither finished // nor permanently dismissed (aafm_quickconnect_should_render()). It never changes state on load. aafm_quickconnect_render(); echo '

'; } /** * The ordered subject sub-tabs for the Abilities tab. * * Keys are the `subject` slugs each ability declares in the registry; values are the * display labels. Order here is the order the sub-tabs render in. A subject is only * shown if at least one registered ability claims it, so this map can safely list * subjects that have no abilities yet. * * @return array */ function aafm_abilities_subjects(): array { return array( 'content' => __( 'Content', 'agent-abilities-for-mcp' ), 'taxonomies' => __( 'Taxonomies & Terms', 'agent-abilities-for-mcp' ), 'comments' => __( 'Comments', 'agent-abilities-for-mcp' ), 'users' => __( 'Users', 'agent-abilities-for-mcp' ), 'media' => __( 'Media', 'agent-abilities-for-mcp' ), 'site' => __( 'Site & structure', 'agent-abilities-for-mcp' ), ); } /** * Presentation-only sub-grouping for the single 'site' subject panel. * * The catalog keeps one 'site' subject (re-subjecting ~28 entries across six files would churn * a load-bearing contract the registry, MCP buckets, and tests all assert on). This map is * consulted ONLY when rendering the site panel, to split it into readable sub-groups. It never * changes any ability's registry subject. Search is mapped here by NAME even though its registry * subject is 'content', so the operator finds it where they expect it. Any site-subject ability * not listed here falls into a rendered "Other" group, so a future addition is never silently * dropped (the AbilitiesSaveTest guard enforces that). * * @return array}> Group slug => label + ability names. */ function aafm_site_subgroups(): array { return array( 'site_settings' => array( 'label' => __( 'Site settings', 'agent-abilities-for-mcp' ), 'abilities' => array( 'aafm/get-site-settings', 'aafm/update-site-settings', 'aafm/get-post-types', 'aafm/get-taxonomies', 'aafm/get-site-info', 'aafm/list-playbooks', 'aafm/get-playbook', 'aafm/get-activity-log', ), ), 'plugins' => array( 'label' => __( 'Plugins', 'agent-abilities-for-mcp' ), 'abilities' => array( 'aafm/list-plugins', ), ), 'themes' => array( 'label' => __( 'Themes & styles', 'agent-abilities-for-mcp' ), 'abilities' => array( 'aafm/get-active-theme', 'aafm/list-themes', 'aafm/list-templates', 'aafm/get-template', 'aafm/update-template', 'aafm/get-global-styles', ), ), 'blocks' => array( 'label' => __( 'Blocks', 'agent-abilities-for-mcp' ), 'abilities' => array( 'aafm/list-blocks', 'aafm/list-block-types', 'aafm/get-block-type', 'aafm/get-block', 'aafm/create-block', 'aafm/update-block', 'aafm/delete-block', ), ), 'menus' => array( 'label' => __( 'Menus', 'agent-abilities-for-mcp' ), 'abilities' => array( 'aafm/list-menus', 'aafm/get-menu', 'aafm/list-menu-items', 'aafm/create-menu', 'aafm/update-menu', 'aafm/delete-menu', 'aafm/create-menu-item', 'aafm/update-menu-item', 'aafm/delete-menu-item', ), ), 'search' => array( 'label' => __( 'Search', 'agent-abilities-for-mcp' ), 'abilities' => array( 'aafm/search-content', ), ), ); } /** * Render the Abilities tab: subject sub-tabs, each split Reads then Writes, all OFF by default. * * This is presentation only. Every checkbox across every sub-tab lives inside the one * form, so hidden panels still submit - the saved option stays a flat list of enabled * ability names regardless of which sub-tab was visible at save time. * * @return void */ function aafm_render_abilities_tab(): void { $registry = aafm_get_abilities_registry(); $enabled = aafm_get_enabled_abilities(); // Bucket the registry by subject so each panel only walks its own abilities. $by_subject = array(); foreach ( $registry as $name => $meta ) { $subject = (string) ( $meta['subject'] ?? '' ); $by_subject[ $subject ][] = array( 'name' => (string) $name ) + $meta; } // Keep only subjects that actually have abilities, in the declared order. $subjects = array(); foreach ( aafm_abilities_subjects() as $slug => $label ) { if ( ! empty( $by_subject[ $slug ] ) ) { $subjects[ $slug ] = $label; } } // Stats box - sits between the page nav and the sub-tabs, reusing the dashboard .aafm-stat // markup. Total reads the single source of truth (core + every integration manifest total), // the same function the Dashboard uses, so the two tabs can never disagree. Enabled counts // what the operator has turned on, labelled "of N". $ability_total = aafm_available_ability_count(); $ability_enabled = aafm_enabled_ability_count(); echo '
'; aafm_render_stat_head( 'aafm-stat-abilities', __( 'Total abilities', 'agent-abilities-for-mcp' ), 'abilities' ); printf( '
%s
', esc_html( number_format_i18n( $ability_total ) ) ); echo '
'; aafm_render_stat_head( 'aafm-stat-enabled', __( 'Enabled', 'agent-abilities-for-mcp' ), 'bolt' ); // This stat aggregates core abilities plus every integration's total, so unlike the section // counts below it cannot be recomputed from this tab's own checkboxes after a save (the // integration abilities live on a different page load entirely). It carries its own class // so admin.js can set it directly from the AJAX response's authoritative total instead. printf( '
%1$s %2$s
', esc_html( number_format_i18n( $ability_enabled ) ), esc_html( sprintf( /* translators: %d: total number of abilities in the catalog. */ __( 'of %d', 'agent-abilities-for-mcp' ), $ability_total ) ) ); echo ''; echo ''; // .aafm-abilities-stats // Search field only - not the Bridge tab's whole filter component (no risk pills: the // operator asked for a search field, not a search tab, and this tab has a third risk // tier - destructive - the two-pill All/Read Only/Write group has no slot for anyway). // Reuses .aafm-integration-search for identical styling to the other tabs' search boxes. // Kept OUTSIDE the form: the form has a type="submit" Save button, and a search field // inside it would let Enter trigger a save instead of filtering. echo ''; echo '
'; wp_nonce_field( 'aafm_admin', 'aafm_nonce' ); // This form (aafm_save_abilities_page) and the Integrations tab (aafm_save_abilities) both write the enabled list, but // each only renders the toggles for the subjects it owns. Declare the core subjects this form // owns via aafm_scope[] so the server preserves every persisted ability OUTSIDE that scope (the // integration abilities - WooCommerce, Yoast, ACF) from the stored option instead of treating a // save here as a full replace that drops them. Mirrors the Integrations form; resolved by // aafm_resolve_scoped_enabled_input(). $subjects is already the core subjects that have abilities. foreach ( array_keys( $subjects ) as $scope_subject ) { printf( '', esc_attr( (string) $scope_subject ) ); } $groups = array( 'reads' => __( 'Reads', 'agent-abilities-for-mcp' ), 'writes' => __( 'Writes', 'agent-abilities-for-mcp' ), ); $disclosures = aafm_ability_disclosures(); // Expand the subject list into the display tabs that actually render. Every subject maps to one // display tab using its own slug, except 'site', which is split into the six site groups from // aafm_site_subgroups() - each becomes its own top-level chip + panel, taking the place the // single "Site & structure" chip used to hold (after 'media'). This is presentation only: no // ability's registry subject changes (the catalog-lock tests pin those). Each display tab is // { slug, label, rows } where rows are the ability entries to render under it. $display_tabs = aafm_abilities_display_tabs( $subjects, $by_subject, $registry ); // Sub-tab bar - pill style (.aafm-subtabs); .aafm-subject-tab stays the JS hook the // toggle binds to and data-subject is the display-tab slug so panel switching keeps working. // Full WAI-ARIA tabs pattern: each tab carries a stable id + aria-controls pointing at its // panel, roving tabindex (only the active tab is in the tab sequence), and the JS adds // Arrow/Home/End handling. Each panel below carries the matching id + aria-labelledby. // "All" comes first and is the default view. It is not a second copy of the rows: it shows // every subject panel at once under its subject label, so each checkbox still renders once. $all_rows = array(); foreach ( $display_tabs as $tab ) { $all_rows = array_merge( $all_rows, $tab['rows'] ); } $first = 'all'; $all_tab = array( 'label' => __( 'All', 'agent-abilities-for-mcp' ), 'rows' => $all_rows, ); echo '
'; foreach ( array( 'all' => $all_tab ) + $display_tabs as $slug => $tab ) { $is_active = ( $slug === $first ); $tab_id = 'aafm-subject-tab-' . $slug; $panel_id = 'aafm-subject-panel-' . $slug; printf( '', $is_active ? ' is-active' : '', esc_attr( $tab_id ), esc_attr( $panel_id ), $is_active ? 'true' : 'false', $is_active ? '0' : '-1', esc_attr( $slug ), esc_html( $tab['label'] ), esc_html( (string) count( $tab['rows'] ) ) ); } echo '
'; // The All tab's own panel holds only its bulk controls; the rows are the subject panels below. echo '
'; aafm_render_abilities_bulk_toolbar( 'all', $all_rows ); // The Total abilities card above counts integration abilities too; say where // the difference lives so the two numbers do not look like they disagree. $elsewhere = $ability_total - count( $all_rows ); if ( $elsewhere > 0 ) { printf( '

%s

', esc_html( sprintf( /* translators: 1: number of built-in abilities listed here, 2: number of abilities listed on other tabs. */ _n( 'All lists the %1$s built-in abilities. The other %2$s is on the Integrations tab.', 'All lists the %1$s built-in abilities. The other %2$s are on the Integrations tab.', $elsewhere, 'agent-abilities-for-mcp' ), number_format_i18n( count( $all_rows ) ), number_format_i18n( $elsewhere ) ) ) ); } echo '
'; foreach ( $display_tabs as $slug => $tab ) { $tab_rows = $tab['rows']; // The panel's own heading below is just a count ("27 / 27 enabled"), never the subject name, // so the subject label names the group whenever several panels show at once: the All tab // and a cross-tab search. admin.js hides it while a single subject tab is open. printf( '

%2$s

', esc_attr( $slug ), esc_attr( $tab['label'] ), esc_attr( 'aafm-subject-panel-' . $slug ), esc_attr( 'aafm-subject-tab-' . $slug ) ); // Per-tab count badge: how many of this tab's abilities are enabled. $subject_total = count( $tab_rows ); $subject_enabled = 0; foreach ( $tab_rows as $ability ) { if ( in_array( (string) $ability['name'], $enabled, true ) ) { ++$subject_enabled; } } // H2 so the document outline runs H1 (page title) → H2 (subject group) → H3 (Reads/Writes) // → H4 (each ability), with no skipped level. The visible weight is unchanged in CSS. // The enabled figure is wrapped in its own span so admin.js can patch just that number // after a save, instead of this heading reading stale until the next full page load. printf( '

%1$s / %2$s %3$s

', esc_html( (string) $subject_enabled ), esc_html( (string) $subject_total ), esc_html__( 'enabled', 'agent-abilities-for-mcp' ) ); aafm_render_abilities_bulk_toolbar( $slug, $tab_rows ); if ( 'content' === $slug ) { aafm_render_post_types_selector(); } // Each display tab renders its abilities split Reads then Writes where both exist; a tab // with a single risk class renders a flat list (the empty group is skipped). The bare // >Reads< / >Writes< text the panel-structure test keys off lives in the group

. foreach ( $groups as $group => $heading ) { $rows = array(); foreach ( $tab_rows as $ability ) { if ( ( $ability['group'] ?? '' ) === $group ) { $rows[] = $ability; } } if ( empty( $rows ) ) { continue; } $group_enabled = 0; foreach ( $rows as $ability ) { if ( in_array( (string) $ability['name'], $enabled, true ) ) { ++$group_enabled; } } // Same live-update span as the subject heading above; the group's ability rows are // the immediate next sibling (.aafm-ability-list), which is how admin.js finds the // checkbox universe to recount after a save. printf( '

%1$s

%2$s / %3$s
', esc_html( $heading ), esc_html( (string) $group_enabled ), esc_html( (string) count( $rows ) ) ); echo '
'; foreach ( $rows as $ability ) { aafm_render_ability_row( $ability, $enabled, $disclosures ); } echo '
'; } // Rendered after the ability tables as a layout choice - the meta selector belongs below // the abilities it governs. No test depends on this placement: the panel-structure test // slices to the next subject panel (or the form's save status), not a bare

. if ( 'content' === $slug ) { aafm_render_meta_keys_selector(); } if ( 'users' === $slug ) { aafm_render_user_meta_keys_selector(); } if ( 'taxonomies' === $slug ) { aafm_render_term_meta_keys_selector(); } echo ''; } echo '
'; echo '
'; // Future: per-connection / per-client ability allowlist scoping is a separate roadmapped // feature - it would filter $enabled per principal here rather than at render time. } /** * Echo the bulk controls for a set of ability rows: Enable all / Disable all, Enable all reads and, * where it can tick something, Enable all writes. * * Used once per subject panel and once for the All tab, where `$slug` is 'all' and `$tab_rows` is * every row, so the All tab's buttons act on the whole form through the same JS bindings. * * @param string $slug Display-tab slug, or 'all'. * @param array> $tab_rows The ability entries the controls act on. * @return void */ function aafm_render_abilities_bulk_toolbar( string $slug, array $tab_rows ): void { // Per-section enable/disable-all control. JS scopes by .aafm-subject-panel[data-subject] // (the whole form for 'all') and toggles every checkbox in it; data-has-destructive tells it to confirm before // bulk-enabling a section that contains a destructive ability. $has_destructive = false; foreach ( $tab_rows as $ability ) { if ( 'destructive' === (string) ( $ability['risk'] ?? '' ) ) { $has_destructive = true; break; } } printf( '

%5$s

', esc_attr( $slug ), $has_destructive ? ' data-has-destructive="1"' : '', esc_html__( 'Enable all / Disable all', 'agent-abilities-for-mcp' ), esc_html__( 'Enable all reads', 'agent-abilities-for-mcp' ), wp_kses( aafm_enable_writes_button_html( $slug, $tab_rows ), aafm_admin_allowed_html() ) ); } /** * The "Enable all writes" button and its caveat, or '' when no row in the set is an ordinary * write that can be ticked. Shared by the Abilities and Integrations bulk controls. * * "Enable all writes" only renders where it can actually do something. A set with no ordinary * write row, or one where every write is currently locked (read-only mode on, or the high-risk * category shut), would render a button guaranteed to tick nothing - the failure mode that looks * exactly like success. aafm_ability_lock_reason() covers both lock causes; the explicit high-risk * test covers the UNLOCKED high-risk ability, which the lock predicate lets through but this * control still refuses to touch. * * @param string $slug Scope slug, used for the caveat's id. * @param array> $rows The ability entries the button acts on. * @return string Escaped HTML, starting with a space. */ function aafm_enable_writes_button_html( string $slug, array $rows ): string { foreach ( $rows as $ability ) { $write_name = (string) ( $ability['name'] ?? '' ); if ( 'write' !== (string) ( $ability['risk'] ?? '' ) || aafm_ability_is_high_risk( $write_name ) || null !== aafm_ability_lock_reason( $write_name ) ) { continue; } // The note is not decoration: "writes" on its own reads as "everything that changes the // site", and this control deliberately stops short of deletes and the high-risk category. // aria-describedby hangs it off the button so a screen reader announces the caveat with // the control rather than only sighted users seeing it. return sprintf( ' %3$s', esc_attr( 'aafm-write-note-' . sanitize_key( $slug ) ), esc_html__( 'Enable all writes', 'agent-abilities-for-mcp' ), esc_html__( 'Writes only - this leaves deletes and high-risk abilities switched off.', 'agent-abilities-for-mcp' ) ); } return ''; } /** * Render one ability checkbox row. * * Shared by the flat Reads/Writes view and the site sub-group view so both produce identical * markup. The keeps its exact name/value/checked() contract - the save handler and its * tests bind to that, not to this markup. * * @param array $ability The registry entry, with its 'name' key set. * @param array $enabled The enabled ability names. * @param array $disclosures Disclosure text keyed by ability name. * @return void */ function aafm_render_ability_row( array $ability, array $enabled, array $disclosures ): void { $name = (string) ( $ability['name'] ?? '' ); $risk = (string) ( $ability['risk'] ?? 'read' ); $hint = (string) ( $disclosures[ $name ] ?? ( $ability['description'] ?? '' ) ); // Per-ability id on the title

, used as the checkbox's accessible name via // aria-labelledby - otherwise a screen reader announces the bare toggle as just // "checkbox". sanitize_key keeps the slug DOM-safe (ability names hold a slash). $title_id = 'aafm-ability-title-' . sanitize_key( $name ); // data-risk is what the "Enable all reads" bulk control scopes on, and it mirrors the attribute // the Integrations rows already carry so one JS binding serves both tabs. // // data-high-risk is the second axis "Enable all writes" has to scope on, and it exists because // risk alone cannot express it: every one of the ten built-in high-risk abilities is // risk="write" (see aafm_high_risk_abilities_builtin()), so a plain data-risk="write" sweep // would tick refunds, order-status changes, coupons and tax rates the moment the operator // unlocked the category. While the category is locked there is no checkbox to sweep at all; // this attribute covers the unlocked window, which is a supported configuration. Emitted as a // server-rendered fact for the same reason data-risk is, rather than guessed from the badge. printf( '
', esc_attr( $risk ), aafm_ability_is_high_risk( $name ) ? ' data-high-risk="1"' : '' ); // Presentation only. The security boundary is the subtraction in aafm_get_enabled_abilities(); // this just stops the screen offering a switch that the registration walk would ignore. Native // aafm/* rows only - a bridged ability is registered through its own walk, and this renderer is // never called for one (the Bridge tab has its own). $lock_reason = aafm_render_ability_toggle_control( $name, $title_id, $enabled ); echo '
'; printf( '

%2$s

%3$s', esc_attr( $title_id ), esc_html( (string) ( $ability['label'] ?? $name ) ), esc_attr( $risk ) ); // Read-only badge only on read-risk rows; never on write/destructive. risk === 'read' is the // authoritative read-only signal at render time (the catalog carries no annotations.readonly). if ( 'read' === $risk ) { echo ' ' . esc_html__( 'read-only', 'agent-abilities-for-mcp' ) . ''; } // Membership in the high-risk category does not change when the operator unlocks it, so the // badge stays on an unlocked, enabled row too. It marks what the ability can do, not whether // the floor is currently in the way. if ( aafm_ability_is_high_risk( $name ) ) { echo ' ' . esc_html__( 'high-risk', 'agent-abilities-for-mcp' ) . ''; } printf( '

%1$s

', esc_html( $hint ) ); if ( null !== $lock_reason ) { echo '

' . esc_html( aafm_ability_lock_note( $lock_reason ) ) . '

'; } echo '
'; } /** * Expand the subject list into the ordered display tabs the Abilities tab renders. * * Each Abilities-tab subject maps to one display tab keyed by its own slug, EXCEPT 'site', which is * split into the six groups from aafm_site_subgroups() - each becomes its own display tab (chip + * panel), inserted where the single "Site & structure" chip used to sit. This is presentation only: * no ability's registry subject changes. A group's rows are pulled from the full registry by name, * so an ability mapped in by name from another subject (Search's search-content, registry subject * 'content') still lands under its site group. Any site-subject ability no group claims is folded * into the Site settings tab, so a future addition is never silently dropped (the AbilitiesSaveTest * union guard enforces that). * * @param array $subjects Used Abilities-tab subjects, slug => label, in order. * @param array>> $by_subject Registry rows bucketed by subject (with 'name'). * @param array> $registry The full registry, for by-name lookups. * @return array>}> Display tabs, slug => { label, rows }. */ function aafm_abilities_display_tabs( array $subjects, array $by_subject, array $registry ): array { $site_groups = aafm_site_subgroups(); // Which site-subject abilities a group has claimed, so the rest fall into Site settings. $claimed = array(); foreach ( $site_groups as $group ) { foreach ( $group['abilities'] as $ability_name ) { $claimed[ $ability_name ] = true; } } // search-content and any other ability mapped into a site group by name carries a foreign // registry subject; it renders under its site group only, never in the flat view of its subject. $relocated = array(); foreach ( $site_groups as $group ) { foreach ( $group['abilities'] as $ability_name ) { if ( isset( $registry[ $ability_name ] ) && 'site' !== (string) ( $registry[ $ability_name ]['subject'] ?? '' ) ) { $relocated[ $ability_name ] = true; } } } $tabs = array(); foreach ( $subjects as $slug => $label ) { if ( 'site' !== $slug ) { // A plain subject tab: its own rows, minus any relocated into a site group by name. $rows = array(); foreach ( $by_subject[ $slug ] as $ability ) { if ( isset( $relocated[ (string) $ability['name'] ] ) ) { continue; } $rows[] = $ability; } $tabs[ $slug ] = array( 'label' => $label, 'rows' => $rows, ); continue; } // The 'site' slot expands into one display tab per site group, in map order. foreach ( $site_groups as $group_slug => $group ) { $rows = array(); foreach ( $group['abilities'] as $ability_name ) { if ( ! isset( $registry[ $ability_name ] ) ) { continue; } $rows[] = array( 'name' => $ability_name ) + $registry[ $ability_name ]; } // Fold any unclaimed site-subject ability into Site settings, so nothing is dropped. if ( 'site_settings' === $group_slug ) { foreach ( $by_subject['site'] ?? array() as $ability ) { $name = (string) ( $ability['name'] ?? '' ); if ( '' !== $name && ! isset( $claimed[ $name ] ) ) { $rows[] = $ability; $claimed[ $name ] = true; // Render once, even if listed twice. } } } $tabs[ $group_slug ] = array( 'label' => (string) $group['label'], 'rows' => $rows, ); } } return $tabs; } /** * Render the "Exposed content types" opt-in selector inside the Content sub-tab. * * Lists every eligible (public, non-internal) CPT except post/page (always-on). Each row * names the exact fields the agent can read and flags read-only (non map_meta_cap) types, * so the operator opts in informed. Saved by the tab's one Save (aafm_save_abilities_page); the * stored option is always re-floored on read, so the UI is a convenience, not the gate. * * @return void */ function aafm_render_post_types_selector(): void { $eligible = array_values( array_diff( aafm_eligible_post_types(), array( 'post', 'page' ) ) ); $allowed = aafm_allowed_post_types(); $head = '
' . aafm_card_head_html( 'file', __( 'Exposed content types', 'agent-abilities-for-mcp' ) ) . '

' . esc_html__( 'Posts and pages are always available. Any custom content type is off until you turn it on here. The agent can read only these fields of an exposed type: title, slug, excerpt, status, link, dates, author id.', 'agent-abilities-for-mcp' ) . '

'; if ( empty( $eligible ) ) { echo '
' . wp_kses( $head, aafm_admin_allowed_html() ); aafm_render_notice( 'info', __( 'No custom content types on this site are eligible to expose. Only public, non-internal types can be offered here.', 'agent-abilities-for-mcp' ) ); echo '
'; return; } // The selector is a plain
(never a nested
): only the outer abilities // may open a form here, and the tab's one Save button saves this section. echo '
' . wp_kses( $head, aafm_admin_allowed_html() ); echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $eligible as $type ) { $obj = get_post_type_object( $type ); $label = $obj instanceof WP_Post_Type ? $obj->labels->singular_name : $type; $caps = aafm_type_caps( $type ); $mapped = $caps['mapped']; $rest = $obj instanceof WP_Post_Type && $obj->show_in_rest; printf( '', esc_attr( $type ), checked( in_array( $type, $allowed, true ), true, false ), esc_html( (string) $label ), esc_html( $type ), $mapped ? esc_html__( 'Allowed', 'agent-abilities-for-mcp' ) : '' . esc_html__( 'read-only - writes need map_meta_cap', 'agent-abilities-for-mcp' ) . '', $rest ? esc_html__( 'yes', 'agent-abilities-for-mcp' ) : esc_html__( 'no', 'agent-abilities-for-mcp' ), esc_attr( sprintf( /* translators: %s: the content type's singular label, e.g. "Product". */ __( 'Expose %s to agents', 'agent-abilities-for-mcp' ), (string) $label ) ) ); } echo '
' . esc_html__( 'Expose', 'agent-abilities-for-mcp' ) . '' . esc_html__( 'Type', 'agent-abilities-for-mcp' ) . '' . esc_html__( 'Writes', 'agent-abilities-for-mcp' ) . '' . esc_html__( 'REST', 'agent-abilities-for-mcp' ) . '
%3$s %4$s%5$s%6$s
'; echo '
'; // .aafm-table-wrap aafm_render_notice( 'warning', __( 'Exposed types are still gated by that type\'s capabilities and your low-privilege agent user. Only expose types whose title, slug, and excerpt are not sensitive - for example, a type that stores a person\'s name in the title would make that name readable.', 'agent-abilities-for-mcp' ) ); echo ''; echo '
'; } /** * Shared renderer for a two-textarea (exposed + denied) meta-key selector card. * * Aafm_render_meta_keys_selector(), aafm_render_user_meta_keys_selector(), and * aafm_render_term_meta_keys_selector() are thin wrappers around this: they resolve their * own getters, do their own '*' sentinel restore, and hand the result plus their own copy * strings and element ids here. Only the post-meta wrapper passes 'detected', rendering the * "detected on your exposed types" chip row the user/term wrappers don't have. All three * share the same plain
(never a nested ) and no save control of their own, so the one * outer abilities is never closed early. * * @param array $cfg { * Render config. * @type string $container_id Wrapping
id. * @type string $exposed_title Card title, above the exposed textarea. * @type string $exposed_description Paragraph opening the card body. * @type string $warning Text for the warning notice between them. * @type string[] $exposed Exposed keys ('*' already restored if set). * @type string $exposed_field_name Textarea name attribute. * @type string $exposed_textarea_id Textarea id attribute. * @type string $exposed_label_id Id the card title carries, referenced by aria-labelledby. * @type string $exposed_hint_id Id the hint paragraph carries. * @type string[]|null $detected Detected keys to render as chips, or null to * skip the whole "detected" section. * @type string $denied_title H3 text above the denied textarea. * @type string[] $denied Denied keys ('*' already restored if set). * @type string $denied_field_name Textarea name attribute. * @type string $denied_textarea_id Textarea id attribute. * @type string $denied_label_id Id the h3 carries. * @type string $denied_hint_id Id the hint paragraph carries. * @type string $section Section key the tab's one Save sends, for data-aafm-section. * @type string $status_class Class on the hidden section result line. * } * @return void */ function aafm_render_meta_keys_pair( array $cfg ): void { echo '
'; echo '
' . wp_kses( aafm_card_head_html( 'tag', $cfg['exposed_title'], $cfg['exposed_label_id'] ), aafm_admin_allowed_html() ) . '
'; echo '
'; echo '

' . esc_html( $cfg['exposed_description'] ) . '

'; aafm_render_notice( 'warning', $cfg['warning'] ); printf( '', esc_attr( $cfg['exposed_field_name'] ), esc_attr( $cfg['exposed_textarea_id'] ), esc_attr( $cfg['exposed_label_id'] ), esc_attr( $cfg['exposed_hint_id'] ), esc_textarea( implode( "\n", $cfg['exposed'] ) ) ); echo '

' . esc_html__( 'One key per line. * matches any key.', 'agent-abilities-for-mcp' ) . '

'; if ( null !== $cfg['detected'] ) { echo '

' . esc_html__( 'Detected on your exposed types', 'agent-abilities-for-mcp' ) . '

'; if ( empty( $cfg['detected'] ) ) { echo '

' . esc_html__( 'Nothing detected yet on the types you expose.', 'agent-abilities-for-mcp' ) . '

'; } else { echo '
'; foreach ( $cfg['detected'] as $key ) { printf( '', esc_attr( $key ), esc_html( $key ) ); } echo '
'; } } // Deny list, below the exposed list. Denied keys always win over the exposed list, even // when it uses *. The chip source above writes only into the exposed textarea. echo '

' . esc_html( $cfg['denied_title'] ) . '

'; printf( '', esc_attr( $cfg['denied_field_name'] ), esc_attr( $cfg['denied_textarea_id'] ), esc_attr( $cfg['denied_label_id'] ), esc_attr( $cfg['denied_hint_id'] ), esc_textarea( implode( "\n", $cfg['denied'] ) ) ); echo '

' . esc_html__( 'Denied keys win over exposed, even with *. One per line.', 'agent-abilities-for-mcp' ) . '

'; echo ''; echo '
'; } /** * Render the "Exposed meta keys" opt-in selector inside the Content sub-tab. * * One key per line in the textarea is the allowlist; chips below offer the meta keys * actually detected on the exposed types as one-click adds. Saved by the tab's one Save * (aafm_save_abilities_page); the stored allowlist is always re-floored against the * hard-block on read, so this UI is a convenience, not the gate. It mirrors the post-types * selector exactly: a plain
(never a nested ) with no save control of its own, so the * one outer abilities is never closed early. * * @return void */ function aafm_render_meta_keys_selector(): void { $allowed = aafm_allowed_meta_keys(); $denied = aafm_denied_meta_keys(); $detected = aafm_detected_meta_keys(); // The getters strip the '*' sentinel, so surface it in the textareas when the wildcard is // set - otherwise the box renders empty and a re-save would silently wipe allow-all/deny-all. if ( aafm_meta_allow_has_star() ) { array_unshift( $allowed, '*' ); } if ( aafm_meta_deny_has_star() ) { array_unshift( $denied, '*' ); } aafm_render_meta_keys_pair( array( 'container_id' => 'aafm-meta-keys-form', 'exposed_title' => __( 'Exposed meta keys', 'agent-abilities-for-mcp' ), 'exposed_description' => __( 'One meta key per line. These are the only meta keys an agent can read or write on a post it can already edit. Everything else stays hidden.', 'agent-abilities-for-mcp' ), 'warning' => __( 'Meta can hold private data. Only expose keys whose values are safe for an agent to read and write. Protected keys (anything starting with an underscore) and authentication keys are blocked for good and can\'t be added.', 'agent-abilities-for-mcp' ), 'exposed' => $allowed, 'exposed_field_name' => 'aafm_meta_keys', 'exposed_textarea_id' => 'aafm-meta-keys', 'exposed_label_id' => 'aafm-meta-keys-label', 'exposed_hint_id' => 'aafm-meta-keys-hint', 'detected' => $detected, 'denied_title' => __( 'Denied meta keys', 'agent-abilities-for-mcp' ), 'denied' => $denied, 'denied_field_name' => 'aafm_deny_meta_keys', 'denied_textarea_id' => 'aafm-deny-meta-keys', 'denied_label_id' => 'aafm-deny-meta-keys-label', 'denied_hint_id' => 'aafm-deny-meta-keys-hint', 'section' => 'meta_keys', 'status_class' => 'aafm-meta-keys-status', ) ); } /** * Render the exposed/denied user-meta selector for the Users sub-tab. * * Mirrors aafm_render_meta_keys_selector() but for user meta: a plain
(never a nested * ) with two textareas (exposed above denied) and no save control of its own, so the one outer * abilities is never closed early. The deny list always wins over the exposed list, * even when the exposed list uses *. * * @return void */ function aafm_render_user_meta_keys_selector(): void { $exposed = aafm_allowed_user_meta_keys(); $denied = aafm_denied_user_meta_keys(); // The getters strip the '*' sentinel, so surface it in the textareas when the wildcard is // set - otherwise the box renders empty and a re-save would silently wipe allow-all/deny-all. if ( aafm_user_meta_allow_has_star() ) { array_unshift( $exposed, '*' ); } if ( aafm_user_meta_deny_has_star() ) { array_unshift( $denied, '*' ); } aafm_render_meta_keys_pair( array( 'container_id' => 'aafm-user-meta-keys-form', 'exposed_title' => __( 'Exposed user meta keys', 'agent-abilities-for-mcp' ), 'exposed_description' => __( 'These are the only user meta keys an agent can read or write on a user it can already edit. Denied keys always win, even when the exposed list uses *.', 'agent-abilities-for-mcp' ), 'warning' => __( 'User meta can hold private data. Only expose keys whose values are safe for an agent to read and write. Authentication keys, capabilities, and password keys are blocked for good and cannot be added.', 'agent-abilities-for-mcp' ), 'exposed' => $exposed, 'exposed_field_name' => 'aafm_exposed_user_meta_keys', 'exposed_textarea_id' => 'aafm-exposed-user-meta-keys', 'exposed_label_id' => 'aafm-exposed-user-meta-keys-label', 'exposed_hint_id' => 'aafm-exposed-user-meta-keys-hint', 'detected' => null, 'denied_title' => __( 'Denied user meta keys', 'agent-abilities-for-mcp' ), 'denied' => $denied, 'denied_field_name' => 'aafm_denied_user_meta_keys', 'denied_textarea_id' => 'aafm-denied-user-meta-keys', 'denied_label_id' => 'aafm-denied-user-meta-keys-label', 'denied_hint_id' => 'aafm-denied-user-meta-keys-hint', 'section' => 'user_meta_keys', 'status_class' => 'aafm-user-meta-keys-status', ) ); } /** * Render the exposed/denied term-meta selector for the Taxonomies & Terms sub-tab. * * Mirrors aafm_render_user_meta_keys_selector() but for term meta: a plain
(never a * nested ) with two textareas (exposed above denied) and no save control of its own, so the * one outer abilities is never closed early. The deny list always wins over the exposed * list, even when the exposed list uses *. * * @return void */ function aafm_render_term_meta_keys_selector(): void { $exposed = aafm_allowed_term_meta_keys(); $denied = aafm_denied_term_meta_keys(); // The getters strip the '*' sentinel, so surface it in the textareas when the wildcard is // set - otherwise the box renders empty and a re-save would silently wipe allow-all/deny-all. if ( aafm_term_meta_allow_has_star() ) { array_unshift( $exposed, '*' ); } if ( aafm_term_meta_deny_has_star() ) { array_unshift( $denied, '*' ); } aafm_render_meta_keys_pair( array( 'container_id' => 'aafm-term-meta-keys-form', 'exposed_title' => __( 'Exposed term meta keys', 'agent-abilities-for-mcp' ), 'exposed_description' => __( 'These are the only term meta keys an agent can read or write on a term it can already edit. Denied keys always win, even when the exposed list uses *.', 'agent-abilities-for-mcp' ), 'warning' => __( 'Term meta can hold private data. Only expose keys whose values are safe for an agent to read and write. Protected keys (anything starting with an underscore) and authentication keys are blocked for good and cannot be added.', 'agent-abilities-for-mcp' ), 'exposed' => $exposed, 'exposed_field_name' => 'aafm_exposed_term_meta_keys', 'exposed_textarea_id' => 'aafm-exposed-term-meta-keys', 'exposed_label_id' => 'aafm-exposed-term-meta-keys-label', 'exposed_hint_id' => 'aafm-exposed-term-meta-keys-hint', 'detected' => null, 'denied_title' => __( 'Denied term meta keys', 'agent-abilities-for-mcp' ), 'denied' => $denied, 'denied_field_name' => 'aafm_denied_term_meta_keys', 'denied_textarea_id' => 'aafm-denied-term-meta-keys', 'denied_label_id' => 'aafm-denied-term-meta-keys-label', 'denied_hint_id' => 'aafm-denied-term-meta-keys-hint', 'section' => 'term_meta_keys', 'status_class' => 'aafm-term-meta-keys-status', ) ); } /** * Render the Activity Log tab (includes denials and errors). * * Every cell renders stored audit data, so each value is escaped on output. The log * holds argument KEYS (never values), a REMOTE_ADDR source IP, and since v5 a short * detail string: an allowlisted identifier, a WP_Error code, or a crash's class name and * file:line. A code returned by a bridged foreign ability is prefixed `foreign:` and kept only * when it is a short key-shaped string, never its message, so there is no free text to redact * here beyond standard escaping. * * @return void */ function aafm_render_activity_tab(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only filters from the URL; nothing is written. $query = wp_unslash( $_GET ); $filters = aafm_activity_filters_from_request( is_array( $query ) ? $query : array() ); $status = isset( $filters['status'] ) ? (string) $filters['status'] : 'all'; $per_page = aafm_activity_page_size(); $rows = aafm_query_activity( array_merge( $filters, array( 'per_page' => $per_page ) ) ); $total = aafm_activity_count_matching( $filters ); $total_pages = max( 1, (int) ceil( $total / $per_page ) ); $retention = aafm_log_retention_days(); echo '
'; wp_nonce_field( 'aafm_admin', 'aafm_log_nonce' ); aafm_render_activity_summary( aafm_activity_summary() ); // The controls, filters, table and pager sit in one card, like every other tab's content. echo '
'; echo '
' . wp_kses( aafm_card_head_html( 'activity', __( 'Log entries', 'agent-abilities-for-mcp' ) ), aafm_admin_allowed_html() ) . '
'; echo '
'; // Status filter - server-side: each button re-queries page 1 with its status (admin.js). echo '
'; echo '
'; $segments = array( 'all' => __( 'All', 'agent-abilities-for-mcp' ), 'success' => __( 'Success', 'agent-abilities-for-mcp' ), 'error' => __( 'Errors', 'agent-abilities-for-mcp' ), 'denied' => __( 'Denied', 'agent-abilities-for-mcp' ), 'started' => __( 'Started', 'agent-abilities-for-mcp' ), ); foreach ( $segments as $slug => $label ) { $on = $slug === $status; printf( '', $on ? ' is-active on' : '', esc_attr( $slug ), $on ? 'true' : 'false', esc_html( $label ) ); } echo '
'; // Live count headline plus a plain clarifier describing the retention window. $count_note = aafm_activity_count_note( $total, $retention ); printf( '%1$s %2$s', esc_html( number_format_i18n( $total ) ), esc_html( $count_note ) ); // Export link carries the filter as a query arg so "export what I'm looking at" holds; it // starts at "all" (the filter the tab always opens on) and admin.js keeps it in sync with the // segmented filter buttons as the operator switches between them. $export_url = wp_nonce_url( add_query_arg( array_merge( array( 'action' => 'aafm_export_log', 'filter' => $status, ), array_diff_key( $filters, array( 'status' => true, 'max_id' => true, ) ) ), admin_url( 'admin-post.php' ) ), 'aafm_admin' ); printf( '%2$s ', esc_url( $export_url ), esc_html__( 'Export CSV', 'agent-abilities-for-mcp' ) ); echo ' '; echo ' '; echo '
'; aafm_render_activity_filters( $filters ); if ( isset( $filters['row'] ) ) { printf( '

%1$s %3$s

', esc_html__( 'Showing one entry.', 'agent-abilities-for-mcp' ), esc_url( admin_url( 'admin.php?page=agent-abilities-for-mcp&tab=activity' ) ), esc_html__( 'Show all', 'agent-abilities-for-mcp' ) ); } printf( '
', esc_attr( $status ), esc_attr( (string) $total_pages ) ); echo ''; foreach ( aafm_activity_log_headers() as $header_label ) { echo ''; } echo ''; echo wp_kses( aafm_activity_rows_html( $rows, array() !== $filters ), aafm_admin_allowed_html() ); echo '
' . esc_html( $header_label ) . '
'; echo '
'; // .aafm-table-wrap // Pager: Prev / "Page X of Y" / Next. Disabled at the ends; updated by admin.js on filter/page. echo '
'; echo ''; printf( '%s', esc_html( sprintf( /* translators: 1: current page number, 2: total number of pages. */ __( 'Page %1$s of %2$s', 'agent-abilities-for-mcp' ), number_format_i18n( 1 ), number_format_i18n( $total_pages ) ) ) ); printf( '', $total_pages > 1 ? '' : ' disabled', esc_html__( 'Next', 'agent-abilities-for-mcp' ) ); echo '
'; echo '
'; // .aafm-card-pad, .aafm-log-card echo '
'; } /** * The words after the entry count: "entry" or "entries", then what the log keeps. * * The AJAX page and clear-log responses carry this string for the count they return, so the plural form * is chosen by _n() and not by the script. * * @param int $total How many entries the count above shows. * @param int $retention Days the log keeps entries, 0 for everything. * @return string */ function aafm_activity_count_note( int $total, int $retention ): string { if ( $retention > 0 ) { $keeping = sprintf( /* translators: %s: number of days of activity the log keeps. */ _n( 'keeping the last %s day', 'keeping the last %s days', $retention, 'agent-abilities-for-mcp' ), number_format_i18n( $retention ) ); } else { $keeping = __( 'keeping everything', 'agent-abilities-for-mcp' ); } return sprintf( /* translators: 1: "entry" or "entries", 2: what the log keeps, for example "keeping the last 30 days". */ __( '%1$s, %2$s', 'agent-abilities-for-mcp' ), _n( 'entry', 'entries', $total, 'agent-abilities-for-mcp' ), $keeping ); } /** * Number of activity rows shown per page in the admin table. * * @return int Positive page size, capped at the aafm_query_activity() ceiling (200). */ function aafm_activity_page_size(): int { $size = (int) apply_filters( 'aafm_activity_page_size', 50 ); return min( 200, max( 1, $size ) ); } /** * Neutralise a spreadsheet formula prefix in one CSV cell. * * Every field the log stores is a constrained identifier, so this is defence in depth rather than * a live hole. It exists because a CSV export's most likely destination is a spreadsheet, and * detail and arg_keys are the widest fields in the table. * * @param string $value Cell value. * @return string */ function aafm_csv_cell( string $value ): string { return ( '' !== $value && false !== strpbrk( $value[0], "=+-@\t\r" ) ) ? "'" . $value : $value; } /** * Append one unmistakable CSV row flagging that the export stopped early because a database read * failed, rather than because the log was exhausted. * * A failed page read and "no more rows" both surface as an empty result from * aafm_query_activity() - a caller that cannot tell them apart writes a file that LOOKS complete * but silently drops every row after the failure. An audit export is evidence; a truncated one * that claims completeness is worse than no export at all, so a failure has to be visible in the * file itself rather than swallowed. The row content deliberately overflows and skews the normal * column shape (a status no real row ever carries, an all-caps message repeated into more than * one cell) so it reads as an anomaly at a glance rather than blending into ordinary data. * * @param resource $out The open php://output stream. * @return void */ function aafm_write_export_failure_row( $out ): void { $message = __( 'EXPORT INCOMPLETE - a database read failed partway through. Rows after this point were NOT exported. Re-run the export.', 'agent-abilities-for-mcp' ); fputcsv( $out, array( 'EXPORT INCOMPLETE', 'export_failed', 'EXPORT INCOMPLETE', $message, 'error', '', '', '', '', '', '', '', '' ), ',', '"', '' ); } /** * Stream the activity log to php://output as CSV. * * Batched at aafm_query_activity()'s existing 200-row page cap rather than pulling the whole * table in one query - that cap is a safety property, and routing around it would trade a bounded * query for an unbounded one on a table that can hold tens of thousands of rows. Memory stays flat * regardless of table size: each page is fetched, written, and released before the next page loads. * * Every page is bound to a snapshot of the highest row id taken before the first page runs, not * just an OFFSET into "however many rows exist right now". Without that bound, a row inserted * while the export is mid-run shifts the OFFSET window and the same row can be written twice - * nothing is skipped, but a duplicate in a compliance export is still a real defect. * * A failed read - the id snapshot, or any page - is never treated as "nothing left to export": * both aafm_activity_max_id_result() and aafm_query_activity_result() report ok=false * separately from a genuine empty result, and either one stops the export and appends an * unmistakable failure row via aafm_write_export_failure_row() rather than completing quietly. * Headers (admin-post.php's handler) are already sent by the time this runs, so an HTTP-level * refusal is not on the table - marking the file itself is the only way to keep the operator from * mistaking a truncated download for a complete one. * * Time limits and connection-abort handling are lifted for the same reason a partial export is * dangerous here: an operator relying on a compliance export getting cut off with a file that * still looks complete is worse than the request failing outright, so each batch is flushed to * the client as soon as it is written rather than held until the whole export finishes. * * @param array $filters Filter set matching the tab's current view. * @return void */ function aafm_export_activity_csv( array $filters = array() ): void { $filters = aafm_activity_filters_normalize( $filters ); $columns = array( 'created_at', 'event_type', 'ability', 'detail', 'status', 'arg_keys', 'principal_login', 'principal_user_id', 'source_ip', 'client_id', 'result_count', 'duration_ms', 'connection_name', ); if ( function_exists( 'set_time_limit' ) ) { set_time_limit( 0 ); } ignore_user_abort( true ); // php://output is a stream wrapper, not a real filesystem path, so WP_Filesystem (which only // targets the real filesystem) cannot write to it - fopen()/fclose() are the correct tool here. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $out = fopen( 'php://output', 'w' ); if ( false === $out ) { return; } // PHP 8.4 deprecated relying on fputcsv()'s implicit default escape character, so it's passed // explicitly here. An empty string is also the RFC 4180-correct behaviour: no escape prefix, // just double-doubled quotes around an embedded enclosure character. fputcsv( $out, $columns, ',', '"', '' ); $max_id_view = aafm_activity_max_id_result(); $max_id = $max_id_view['value']; if ( ! $max_id_view['ok'] ) { aafm_write_export_failure_row( $out ); } else { $page = 1; while ( true ) { $view = aafm_query_activity_result( array_merge( $filters, array( 'per_page' => 200, 'page' => $page, 'max_id' => $max_id, ) ) ); if ( ! $view['ok'] ) { aafm_write_export_failure_row( $out ); break; } $rows = $view['rows']; $names = aafm_activity_connection_names( array_column( $rows, 'client_id' ) ); foreach ( $rows as $row ) { $line = array(); foreach ( $columns as $column ) { $cell = 'connection_name' === $column ? aafm_activity_connection_label( $row, $names ) : (string) ( $row[ $column ] ?? '' ); $line[] = aafm_csv_cell( 'detail' === $column ? aafm_localize_denied_detail( $cell ) : $cell ); } fputcsv( $out, $line, ',', '"', '' ); } // Testing hook: lets the test suite insert rows between batches to prove the max_id // snapshot above keeps them out of this export instead of shifting the OFFSET window. do_action( 'aafm_activity_export_batch', $page, $rows ); flush(); if ( count( $rows ) < 200 ) { break; // A genuinely-short page: the log is exhausted, not a failed read. } ++$page; } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose fclose( $out ); } /** * Render one page of activity rows as escaped HTML. * * Every cell is run through esc_html()/esc_attr(); the log holds argument KEYS (never values), * a REMOTE_ADDR source IP, and since v5 a short detail string. The detail is key-shaped: * includes/audit/detail.php is an allowlist of field types with no string or text type, and its * one non-map writer (aafm_build_activity_detail_from_exception(), for a caught crash) emits only * a class name and a file:line. A first-party detail never carries an argument value. A code * returned by a bridged foreign ability is prefixed `foreign:` and kept only when it is a short * key-shaped string, and a plugin that builds a code from its input can place a short fragment of that * input there. Standard escaping is sufficient for all of it. An empty set renders a single "no activity" row so the table never * collapses to a bare . * * @param array> $rows Rows from aafm_query_activity(). * @param bool $filtered Whether filters are applied, which changes what an empty table says. * @return string Escaped … markup. */ function aafm_activity_rows_html( array $rows, bool $filtered = false ): string { if ( empty( $rows ) ) { $empty = $filtered ? __( 'No entries match these filters.', 'agent-abilities-for-mcp' ) : __( 'No activity recorded yet.', 'agent-abilities-for-mcp' ); return '' . esc_html( $empty ) . ''; } $names = aafm_activity_connection_names( array_column( $rows, 'client_id' ) ); $html = ''; foreach ( $rows as $row ) { $v = aafm_activity_row_view( $row, $names ); $html .= sprintf( // The short columns clip with an ellipsis, so each carries its full text as a title. '%1$s%2$s%3$s%4$s%5$s%8$s%9$s%10$s%11$s', esc_html( $v['time'] ), esc_html( $v['principal'] ), esc_html( $v['connection'] ), esc_html( $v['ability'] ), aafm_activity_detail_html( $v['ability'], $v['detail'] ), esc_attr( $v['variant'] ), esc_attr( $v['status'] ), esc_html( $v['status'] ), esc_html( $v['duration'] ), esc_html( $v['ip'] ), esc_html( $v['arg_keys'] ), 1 === preg_match( '/^\s*[\[{]/', (string) $v['detail'] ) ? ' is-json' : '', esc_attr( (string) $v['detail'] ) ); } return $html; } /** * Locate the single object identifier in a raw detail string and, when the ability's declared * link type resolves to a real edit URL, split the detail into the parts either renderer needs * to build a link around it. * * Operates on the RAW (unescaped) detail text - never on an already-escaped one. Matching against * esc_html() output is a real bug, not a theoretical one: esc_html() turns an apostrophe into * `'`, and `#039` satisfies `/#(\d+)/` just as well as a real id, so an escaped string can * match the wrong id entirely (or the right id at the wrong offset). Byte offsets from * PREG_OFFSET_CAPTURE are safe to slice with substr() here because the pattern only ever matches * ASCII `#` and digits, so a split point can never land inside a multi-byte UTF-8 sequence. * * The link type comes from the ability's own declaration in aafm_activity_detail_map(), never from * pattern-matching the text itself - a string that merely looks like it holds an id is left alone. * The activity tab is already manage_options-gated, so linking adds no exposure beyond what the * viewer can already reach through the object's own edit screen. A deleted or inaccessible object * (get_edit_*_link() returns falsy) is reported as no link, never a dead one. * * @param string $ability Ability name from the row. * @param string $detail_raw Raw (unescaped) detail text from the log. * @return array{before:string,id:int,url:string,after:string}|null Null when there is nothing to link. */ function aafm_activity_detail_link( string $ability, string $detail_raw ): ?array { $type = aafm_activity_detail_link_type( $ability ); if ( null === $type || ! preg_match( '/#(\d+)/', $detail_raw, $matches, PREG_OFFSET_CAPTURE ) ) { return null; } $id = (int) $matches[1][0]; $offset = $matches[0][1]; $match_len = strlen( $matches[0][0] ); switch ( $type ) { case 'post': $url = get_edit_post_link( $id ); break; case 'user': $url = get_edit_user_link( $id ); break; case 'term': $url = aafm_exact_object( 'term', $id ) instanceof WP_Term ? get_edit_term_link( $id ) : null; break; case 'order': // Never hand-build this URL. Under HPOS a WooCommerce order's edit screen is not // post.php?post=N, and order rows are exactly where that assumption is likeliest. // An order post that does not load exactly gets no link. $order = ( function_exists( 'wc_get_order' ) && ( false === aafm_wc_store_is_core( 'order' ) || aafm_exact_object( 'post', $id ) instanceof WP_Post ) ) ? wc_get_order( $id ) : null; $url = ( $order && method_exists( $order, 'get_edit_order_url' ) ) ? $order->get_edit_order_url() : null; break; default: $url = null; } if ( ! $url ) { return null; // Deleted or inaccessible object: plain text, never a dead link. } return array( 'before' => substr( $detail_raw, 0, $offset ), 'id' => $id, 'url' => $url, 'after' => substr( $detail_raw, $offset + $match_len ), ); } /** * Turn the single object identifier in a detail string into an edit link, when the acting admin * can reach it, escaping the rest of the string around it. * * @param string $ability Ability name from the row. * @param string $detail_raw Raw (unescaped) detail text from the log. * @return string Escaped HTML: the detail text, possibly with one linked identifier. */ function aafm_activity_detail_html( string $ability, string $detail_raw ): string { $link = aafm_activity_detail_link( $ability, $detail_raw ); if ( null === $link ) { return esc_html( $detail_raw ); } return esc_html( $link['before'] ) . '#' . (int) $link['id'] . '' . esc_html( $link['after'] ); } /** * Render a single troubleshooting entry as a native
accordion. * * The question (summary) and body are pre-built, escaped HTML fragments. Bodies may * carry inline ,

,