509318396
­­­­­­­­­­­­­­­­­­
<!DOCTYPE html>
<html>
<?php
/**
 * Append-only audit trail of every write-tool call, surfaced in the
 * Settings tab ("who / what / when changed our pixel config?"). Each row
 * holds `ts`, `tool`, `mcp_note`, `actor_ip`, `token`, `result_status` and
 * `source`; stored via {@see Storage} keyed by microtime (lex-sort =
 * time-sort), FIFO-trimmed. Calls through our endpoint are logged from the
 * adapter filter when the tool's annotations mark it as a write
 * (`readOnlyHint === false` OR `destructiveHint === true`); write calls from
 * other MCP servers / the core REST API / WP-CLI come in via
 * {@see recordExternalWrite()}.
 * Rows written before `source` existed came from our endpoint.
 *
 * @package PixelYourSite\MCP
 */

declare( strict_types = 1 );

namespace PixelYourSite\MCP;

if ( !defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

final class Provenance {

	/** Soft cap on retained provenance rows. FIFO-trimmed by Storage on each append. */
	public const SOFT_CAP = 500;

	/** Recognised `result_status` values. */
	public const STATUS_SUCCESS = 'success';
	public const STATUS_ERROR   = 'error';
	public const STATUS_PREVIEW = 'preview';

	/** Recognised `source` values. */
	public const SOURCE_PYS      = 'pys';
	public const SOURCE_EXTERNAL = 'external';

	/** `token` prefix for external rows; followed by the WP user login. */
	public const EXTERNAL_TOKEN_PREFIX = 'external:';

	/** `token` of rows written from WP-CLI; followed by the WP user login when there is one. */
	public const CLI_TOKEN = 'cli';

	/** Set while our server runs a tool; its result filter logs that call. */
	private static bool $inOurToolCall = false;

	private string $serverId;

	/**
	 * Bind this provenance logger to one adapter server.
	 *
	 * @param string $serverId Adapter server ID this instance logs for.
	 */
	public function __construct( string $serverId ) {
		$this->serverId = $serverId;
	}

	/**
	 * Hook the tool-result filter at priority 20, so RequestGuard
	 * (priority 10) updates its failure counter before we write the row.
	 *
	 * @return void
	 */
	public function register(): void {
		add_filter( 'pys_pro_mcp_adapter_pre_tool_call', array( $this, 'onToolCallStart' ), PHP_INT_MAX, 4 );
		add_filter( 'pys_pro_mcp_adapter_tool_call_result', array( $this, 'onToolResult' ), 20, 5 );
	}

	/**
	 * Marks a tool call our server is about to run, so the ability's own
	 * logging skips it. Runs last: a call another filter refused never starts.
	 *
	 * @param mixed  $args      Tool args, or a WP_Error from an earlier filter.
	 * @param string $toolName  Tool name.
	 * @param mixed  $mcpTool   Adapter tool wrapper — opaque here.
	 * @param mixed  $mcpServer Adapter server instance.
	 * @return mixed
	 */
	public function onToolCallStart( $args, $toolName, $mcpTool, $mcpServer ) {
		if ( !is_wp_error( $args ) && $this->isOurServer( $mcpServer ) ) {
			self::$inOurToolCall = true;
		}

		return $args;
	}

	/**
	 * Whether our server is running the current tool call.
	 *
	 * @return bool
	 */
	public static function isInOurToolCall(): bool {
		return self::$inOurToolCall;
	}

	/**
	 * Filter callback. Returns `$result` unchanged (observer, not mutator);
	 * logging is best-effort and never breaks the tool call.
	 *
	 * @param mixed                                 $result    Tool execution result.
	 * @param array                                 $args      Tool args.
	 * @param string                                $toolName  Tool name.
	 * @param mixed                                 $mcpTool   Adapter tool wrapper — opaque here.
	 * @param \PYS_PRO_GLOBAL\WP\MCP\Core\McpServer $mcpServer Adapter server instance.
	 * @return mixed
	 */
	public function onToolResult( $result, $args, string $toolName, $mcpTool, $mcpServer ) {
		if ( !$this->isOurServer( $mcpServer ) ) {
			return $result;
		}
		self::$inOurToolCall = false;
		if ( !self::isWriteTool( $mcpTool ) ) {
			return $result;
		}

		$mcpNote = '';
		if ( is_array( $args ) && isset( $args[ 'mcp_note' ] ) && is_string( $args[ 'mcp_note' ] ) ) {
			$mcpNote = $args[ 'mcp_note' ];
		}

		self::recordEntry(
			array(
				'ts'            => time(),
				'tool'          => $toolName,
				'mcp_note'      => $mcpNote,
				'actor_ip'      => self::clientIp(),
				'token'         => (string) ( Auth::activeTokenLabel() ?? '' ),
				'result_status' => self::resultStatus( $result ),
				'source'        => self::SOURCE_PYS,
			)
		);

		return $result;
	}

	/**
	 * Log a write call that reached an ability outside our endpoint (another
	 * MCP server, the core REST API, WP-CLI). There is no PYS token, so the
	 * row is attributed to the WP user. Best-effort: never breaks the tool call.
	 *
	 * @param string $abilityId Ability ID.
	 * @param mixed  $input     Tool args.
	 * @param mixed  $result    Tool execution result.
	 * @return void
	 */
	public static function recordExternalWrite( string $abilityId, $input, $result ): void {
		try {
			$mcp_note = '';
			if ( is_array( $input ) && isset( $input[ 'mcp_note' ] ) && is_string( $input[ 'mcp_note' ] ) ) {
				$mcp_note = $input[ 'mcp_note' ];
			}

			self::recordEntry(
				array(
					'ts'            => time(),
					'tool'          => ToolNameMap::resolve( $abilityId ) ?? $abilityId,
					'mcp_note'      => $mcp_note,
					'actor_ip'      => self::clientIp(),
					'token'         => self::externalActor(),
					'result_status' => self::resultStatus( $result ),
					'source'        => self::SOURCE_EXTERNAL,
				)
			);
		} catch ( \Throwable $e ) {
			// Logging must not change the tool result.
		}
	}

	/**
	 * `token` of an external row: `cli:<login>` (or `cli`) from WP-CLI,
	 * `external:<login>` otherwise.
	 *
	 * @return string
	 */
	private static function externalActor(): string {
		$login = (string) wp_get_current_user()->user_login;

		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			return '' === $login ? self::CLI_TOKEN : self::CLI_TOKEN . ':' . $login;
		}

		return self::EXTERNAL_TOKEN_PREFIX . $login;
	}

	/**
	 * Row source; rows without one predate the field and came from our endpoint.
	 *
	 * @param array $entry Log row.
	 * @return string One of the SOURCE_* values.
	 */
	public static function entrySource( array $entry ): string {
		return self::SOURCE_EXTERNAL === ( $entry[ 'source' ] ?? '' ) ? self::SOURCE_EXTERNAL : self::SOURCE_PYS;
	}

	/**
	 * Public manual-record entry point. Used by callers that need to log
	 * something the adapter filter wouldn't see — e.g. a settings-tab action
	 * firing outside the MCP request flow.
	 *
	 * @param array $entry Entry to append (ts / tool / mcp_note / actor_ip / token / result_status / source).
	 * @return bool True on successful write.
	 */
	public static function recordEntry( array $entry ): bool {
		return Storage::provenanceAppend( $entry, self::SOFT_CAP );
	}

	/**
	 * Read API for the Settings-tab "Recent Activity" panel. Returns entries
	 * newest-first.
	 *
	 * @param int $limit Max entries to return.
	 * @return array<int, array> Decoded entries.
	 */
	public static function getRecent( int $limit = 20 ): array {
		return Storage::getRecentProvenance( $limit );
	}

	/**
	 * Full log, newest-first. The store is FIFO-capped at {@see SOFT_CAP}
	 * rows, so loading everything is cheap; the activity-log subpage filters
	 * and paginates in PHP (rows are serialized — SQL filtering would mean
	 * fragile LIKE patterns over serialized data).
	 *
	 * @return array<int, array> Decoded entries.
	 */
	public static function getAll(): array {
		return Storage::getRecentProvenance( self::SOFT_CAP );
	}

	/**
	 * Wipe the log. Bound to the Settings-tab "Clear log" button.
	 *
	 * @return int Number of rows deleted.
	 */
	public static function clearAll(): int {
		return Storage::clearAllProvenance();
	}

	/**
	 * Annotation-driven write-tool test. True when the tool declared
	 * `readOnlyHint === false` OR `destructiveHint === true`; false for
	 * everything else (read tools and undeclared tools — default-deny).
	 * Duck-typed via `method_exists` since `$mcpTool` lives under the
	 * prefixed namespace.
	 *
	 * @param mixed $mcpTool Adapter tool wrapper (`McpTool` in practice).
	 * @return bool True when the tool is a write (audit-loggable).
	 */
	public static function isWriteTool( $mcpTool ): bool {
		if ( !is_object( $mcpTool ) || !method_exists( $mcpTool, 'get_protocol_dto' ) ) {
			return false;
		}

		$tool = $mcpTool->get_protocol_dto();
		if ( !is_object( $tool ) || !method_exists( $tool, 'getAnnotations' ) ) {
			return false;
		}

		$annotations = $tool->getAnnotations();
		if ( !is_object( $annotations ) ) {
			return false;
		}

		$readOnly    = method_exists( $annotations, 'getReadOnlyHint' ) ? $annotations->getReadOnlyHint() : null;
		$destructive = method_exists( $annotations, 'getDestructiveHint' ) ? $annotations->getDestructiveHint() : null;

		return false === $readOnly || true === $destructive;
	}

	/**
	 * Row status: an error, a preview that wrote nothing (it asks for
	 * confirmation), or a success.
	 *
	 * @param mixed $result Tool execution result.
	 * @return string One of the STATUS_* values.
	 */
	public static function resultStatus( $result ): string {
		if ( self::isErrorResult( $result ) ) {
			return self::STATUS_ERROR;
		}
		if ( is_array( $result ) && !empty( $result[ 'confirmation_required' ] ) ) {
			return self::STATUS_PREVIEW;
		}

		return self::STATUS_SUCCESS;
	}

	/**
	 * Failure detection matches RequestGuard::recordToolResult — keep them in sync.
	 *
	 * @param mixed $result Tool execution result.
	 * @return bool True when the result represents an error.
	 */
	private static function isErrorResult( $result ): bool {
		if ( is_wp_error( $result ) ) {
			return true;
		}
		if ( is_array( $result ) ) {
			if ( array_key_exists( 'success', $result ) && false === $result[ 'success' ]
			     && !empty( $result[ 'error' ] ) ) {
				return true;
			}
			if ( !empty( $result[ 'isError' ] ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Whether `$mcpServer` is the server this instance logs for.
	 *
	 * @param mixed $mcpServer Adapter server instance.
	 * @return bool
	 */
	private function isOurServer( $mcpServer ): bool {
		if ( !is_object( $mcpServer ) || !method_exists( $mcpServer, 'get_server_id' ) ) {
			return false;
		}

		return $this->serverId === $mcpServer->get_server_id();
	}

	/**
	 * Same definition RequestGuard uses. Reverse-proxy environments must
	 * normalise `REMOTE_ADDR` outside of PYS — we do not parse forwarding
	 * headers here.
	 *
	 * @return string Client IP, or `0.0.0.0` when unknown.
	 */
	private static function clientIp(): string {
		$ip = isset( $_SERVER[ 'REMOTE_ADDR' ] ) ? (string) $_SERVER[ 'REMOTE_ADDR' ] : '';

		return '' === $ip ? '0.0.0.0' : $ip;
	}
}