$data Row data. * @return int Inserted row id. */ public function append( array $data ): int { unset( $data['chain_hash'] ); // Normalize bytes, then redact, both BEFORE hashing so hydrated rows // verify (PRD STORY-9.3, MCP01/MCP10). Normalizing first keeps // malformed payloads distinct instead of letting wp_json_encode // collapse them to identical "?" substitutions. The pending queue's // stored_input is untouched; it must execute verbatim on approval. if ( isset( $data['input'] ) && is_array( $data['input'] ) ) { $data['input'] = self::redact( Utf8::sanitize_deep( $data['input'] ) ); } if ( isset( $data['result'] ) && is_array( $data['result'] ) ) { $data['result'] = Utf8::sanitize_deep( $data['result'] ); } // Falls back to the anchor, not GENESIS: after a prune that removed // every row, the next entry must chain from where verification will // resume, or the two disagree about where the chain begins. $previous = $this->ledger->last_chain_hash() ?? (string) get_option( self::OPTION_ANCHOR, self::GENESIS ); $data['chain_hash'] = hash( 'sha256', $previous . self::canonical_payload_from_data( $data ) ); return $this->ledger->insert( $data ); } /** * Value shapes that are credentials regardless of the key they arrive * under. Tool-poisoning attacks smuggle secrets through innocuous * parameter names, so key-name matching alone is not enough. * * @see https://github.com/invariantlabs-ai/mcp-injection-experiments */ private const SECRET_VALUE_PATTERNS = array( '/-----BEGIN [A-Z ]*PRIVATE KEY-----/', // PEM key material. '/\bag_live_[A-Za-z0-9_-]{20,}/', // Our own agent tokens. '/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/i', // Stripe-style keys. '/\bsk-[A-Za-z0-9]{20,}/', // OpenAI-style keys. '/\bAKIA[0-9A-Z]{16}\b/', // AWS access key ids. '/\bgh[pousr]_[A-Za-z0-9]{20,}/', // GitHub tokens. '/\bxox[abposr]-[A-Za-z0-9-]{10,}/', // Slack tokens. '/\bBearer\s+[A-Za-z0-9._~+\/-]{20,}={0,2}/i', // Inline bearer credentials. ); /** * Recursively replace secret-bearing values with a marker. * * Redacts on two signals: a key name that names a credential, and a value * that is shaped like one. Whole values go, not just the matched span, so * a key wrapped in prose does not leak its remainder. * * @param array $input Input payload. * @return array */ public static function redact( array $input ): array { foreach ( $input as $key => $value ) { if ( is_array( $value ) ) { $input[ $key ] = self::redact( $value ); continue; } if ( is_string( $key ) && 1 === preg_match( '/(password|passwd|secret|token|api_?key|authorization|credential|private_key)/i', $key ) ) { $input[ $key ] = '[REDACTED]'; continue; } if ( is_string( $value ) && self::looks_like_secret( $value ) ) { $input[ $key ] = '[REDACTED]'; } } return $input; } /** * Whether a value carries credential material. * * @param string $value Candidate value. */ private static function looks_like_secret( string $value ): bool { foreach ( self::SECRET_VALUE_PATTERNS as $pattern ) { if ( 1 === preg_match( $pattern, $value ) ) { return true; } } return false; } /** * Walk the whole chain and report the first tampered row. * * @return int|null Id of the first row whose hash does not verify, or null when intact. */ public function verify(): ?int { // Retention removes the oldest rows, so the first survivor references // a predecessor that is gone. Verification resumes from the hash the // prune recorded rather than from GENESIS. Rows after that point still // chain to each other, so tampering inside the surviving range is // caught exactly as before. $previous = (string) get_option( self::OPTION_ANCHOR, self::GENESIS ); $after_id = 0; do { $batch = $this->ledger->after( $after_id ); foreach ( $batch as $row ) { if ( null === $row->chain_hash ) { return $row->id; } $expected = hash( 'sha256', $previous . self::canonical_payload( $row ) ); if ( ! hash_equals( $expected, $row->chain_hash ) ) { return $row->id; } $previous = $row->chain_hash; $after_id = $row->id; } } while ( array() !== $batch ); return null; } /** * Canonical immutable payload of a hydrated row. * * @param LedgerEntry $row Ledger row. */ public static function canonical_payload( LedgerEntry $row ): string { return self::encode( $row->ts, $row->agent_id, $row->wp_user_id, $row->source, $row->action_cat, $row->entity_type, $row->entity_id, $row->ability_name, $row->input, $row->before_hash, $row->policy_id, $row->rule_index ); } /** * Canonical immutable payload from insert data. * * @param array $data Insert data. */ private static function canonical_payload_from_data( array $data ): string { /** @var DateTimeImmutable $ts */ $ts = $data['ts']; return self::encode( $ts, (int) $data['agent_id'], (int) $data['wp_user_id'], (string) $data['source'], (string) $data['action_cat'], (string) $data['entity_type'], isset( $data['entity_id'] ) ? (int) $data['entity_id'] : null, isset( $data['ability_name'] ) ? (string) $data['ability_name'] : null, isset( $data['input'] ) && is_array( $data['input'] ) ? $data['input'] : null, isset( $data['before_hash'] ) ? (string) $data['before_hash'] : null, isset( $data['policy_id'] ) ? (int) $data['policy_id'] : null, isset( $data['rule_index'] ) ? (int) $data['rule_index'] : null ); } /** * Deterministic JSON encoding of the immutable field tuple. * * Input arrays round-trip JSON storage with key order preserved, so the * same logical payload always encodes to the same bytes. * * @param DateTimeImmutable $ts Action timestamp. * @param int $agent_id Acting agent. * @param int $wp_user_id Linked WP user. * @param string $source Interception source. * @param string $action_cat Action category. * @param string $entity_type Entity type. * @param int|null $entity_id Entity id. * @param string|null $ability_name Ability name. * @param array|null $input Input payload. * @param string|null $before_hash Pre-action state hash. * @param int|null $policy_id Policy that decided, null for a default. * @param int|null $rule_index Deciding rule's position. */ private static function encode( DateTimeImmutable $ts, int $agent_id, int $wp_user_id, string $source, string $action_cat, string $entity_type, ?int $entity_id, ?string $ability_name, ?array $input, ?string $before_hash, ?int $policy_id, ?int $rule_index ): string { return (string) wp_json_encode( array( 'ts' => $ts->format( 'Y-m-d H:i:s' ), 'agent_id' => $agent_id, 'wp_user_id' => $wp_user_id, 'source' => $source, 'action_cat' => $action_cat, 'entity_type' => $entity_type, 'entity_id' => $entity_id, 'ability_name' => $ability_name, 'input' => $input, 'before_hash' => $before_hash, 'policy_id' => $policy_id, 'rule_index' => $rule_index, ) ); } }