| 1 |
<?php |
| 2 |
/** |
| 3 |
* Record of what an agent CHANGED on this site (spec 044, FR-039d–h). |
| 4 |
* |
| 5 |
* Answers the question a site owner will ask first: "what did the AI do to my |
| 6 |
* site?" Before 044 that was unanswerable — write access was granted with no |
| 7 |
* record of its use. |
| 8 |
* |
| 9 |
* ## Storage |
| 10 |
* |
| 11 |
* A capped ring buffer in ONE non-autoloaded option, trimmed on append by both |
| 12 |
* entry count and age. Bounded by construction (FR-039e), so it needs no sweep |
| 13 |
* job and cannot grow without limit. Plain get_option/update_option, so on |
| 14 |
* multisite it is per-site automatically (FR-039a) — see Credentials.php for |
| 15 |
* why get_user_option is deliberately avoided. |
| 16 |
* |
| 17 |
* ## Best-effort by design |
| 18 |
* |
| 19 |
* FR-039h: a recording failure MUST NOT fail or roll back the invocation it was |
| 20 |
* describing. Every write here is wrapped and swallowed. This is an accountability |
| 21 |
* aid, not a security control — losing an entry under concurrent append is |
| 22 |
* acceptable and explicitly permitted, which is why no locking is used. |
| 23 |
* |
| 24 |
* @package Templately\Modules\McpCore\Activity |
| 25 |
*/ |
| 26 |
|
| 27 |
namespace Templately\Modules\McpCore\Activity; |
| 28 |
|
| 29 |
class ActivityLog { |
| 30 |
|
| 31 |
const OPTION = 'templately_mcp_activity'; |
| 32 |
|
| 33 |
/** Hard cap on retained entries. */ |
| 34 |
const MAX_ENTRIES = 200; |
| 35 |
|
| 36 |
/** Hard cap on retained age. */ |
| 37 |
const MAX_AGE = 2592000; // 30 days. |
| 38 |
|
| 39 |
/** |
| 40 |
* Append one record. Never throws; never fails the caller (FR-039h). |
| 41 |
* |
| 42 |
* @param string|null $credential_id Credential identifier — NEVER the secret or its hash. |
| 43 |
* @param int $user_id |
| 44 |
* @param string $capability |
| 45 |
* @param bool $success |
| 46 |
* @param string|null $message Short reason on failure. Never a stack trace or path. |
| 47 |
*/ |
| 48 |
public static function record( ?string $credential_id, int $user_id, string $capability, bool $success, ?string $message = null ): void { |
| 49 |
try { |
| 50 |
$entries = self::all(); |
| 51 |
|
| 52 |
$entries[] = [ |
| 53 |
'at' => time(), |
| 54 |
'credential_id' => $credential_id, |
| 55 |
'user_id' => $user_id, |
| 56 |
'capability' => $capability, |
| 57 |
'outcome' => $success ? 'success' : 'failure', |
| 58 |
'message' => $message ? self::sanitize_message( $message ) : null, |
| 59 |
]; |
| 60 |
|
| 61 |
update_option( self::OPTION, self::trim( $entries ), 'no' ); |
| 62 |
} catch ( \Throwable $e ) { |
| 63 |
// Swallowed deliberately — see class docblock (FR-039h). |
| 64 |
return; |
| 65 |
} |
| 66 |
} |
| 67 |
|
| 68 |
/** |
| 69 |
* @return array |
| 70 |
*/ |
| 71 |
public static function all(): array { |
| 72 |
$entries = get_option( self::OPTION, [] ); |
| 73 |
|
| 74 |
return is_array( $entries ) ? $entries : []; |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* Most recent first, for display. |
| 79 |
* |
| 80 |
* @param int $limit |
| 81 |
* @return array |
| 82 |
*/ |
| 83 |
public static function recent( int $limit = 50 ): array { |
| 84 |
$entries = self::trim( self::all() ); |
| 85 |
|
| 86 |
return array_slice( array_reverse( $entries ), 0, max( 1, $limit ) ); |
| 87 |
} |
| 88 |
|
| 89 |
public static function clear(): void { |
| 90 |
delete_option( self::OPTION ); |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* Trim by age first, then by count. Both bounds exist so a busy site does |
| 95 |
* not retain unboundedly and a quiet site does not retain forever. |
| 96 |
* |
| 97 |
* @param array $entries |
| 98 |
* @return array |
| 99 |
*/ |
| 100 |
private static function trim( array $entries ): array { |
| 101 |
$cutoff = time() - self::MAX_AGE; |
| 102 |
|
| 103 |
$entries = array_values( |
| 104 |
array_filter( |
| 105 |
$entries, |
| 106 |
static function ( $entry ) use ( $cutoff ) { |
| 107 |
return isset( $entry['at'] ) && (int) $entry['at'] >= $cutoff; |
| 108 |
} |
| 109 |
) |
| 110 |
); |
| 111 |
|
| 112 |
if ( count( $entries ) > self::MAX_ENTRIES ) { |
| 113 |
$entries = array_slice( $entries, -self::MAX_ENTRIES ); |
| 114 |
} |
| 115 |
|
| 116 |
return $entries; |
| 117 |
} |
| 118 |
|
| 119 |
/** |
| 120 |
* Keep messages short and free of internal detail (FR-039f, FR-044). |
| 121 |
* Anything resembling an absolute path is dropped rather than truncated. |
| 122 |
* |
| 123 |
* @param string $message |
| 124 |
* @return string |
| 125 |
*/ |
| 126 |
private static function sanitize_message( string $message ): string { |
| 127 |
$message = (string) preg_replace( '#(/[^\s:]+)+\.php#', '', $message ); |
| 128 |
$message = trim( wp_strip_all_tags( $message ) ); |
| 129 |
|
| 130 |
if ( function_exists( 'mb_substr' ) ) { |
| 131 |
return mb_substr( $message, 0, 200 ); |
| 132 |
} |
| 133 |
|
| 134 |
return substr( $message, 0, 200 ); |
| 135 |
} |
| 136 |
} |
| 137 |
|