|\Closure(): array $data Row data, or a closure that decides it. * @return int Inserted row id. * @throws LedgerContention When every attempt lost the race. * @throws LedgerUnavailable When the row could not be stored at all. */ public function append( array|\Closure $data ): int { for ( $attempt = 0; $attempt < self::MAX_ATTEMPTS; $attempt++ ) { // Read before deciding, so the decision sees at least what the // head does; a row landing after this moves the head and loses // this attempt the race. $head = $this->ledger->chain_head(); $row = $this->prepare( $data instanceof \Closure ? $data() : $data ); // 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 = $head['hash'] ?? (string) get_option( self::OPTION_ANCHOR, self::GENESIS ); // With no row there is no predecessor, which 0 names. No row can // hold 0 already - it would be the head - so only one of several // first rows lands. Not the id of // the last pruned row: auto-increment ids can be reused once a // table is empty, and a reused id would collide with it forever. $row['prev_id'] = $head['id'] ?? 0; $row['chain_hash'] = hash( 'sha256', $previous . self::canonical_payload_from_data( $row ) ); $id = $this->ledger->insert_chained( $row ); if ( null === $id ) { // Lost the race. A random pause keeps a burst of writers // from retrying in lockstep, and a longer one after each // loss spreads a sustained burst out. Four processes // appending in a tight loop lost all ten attempts on about // 0.7% of appends with a fixed 1-5 ms pause. if ( $attempt < self::MAX_ATTEMPTS - 1 ) { [ $min, $max ] = self::backoff_window( $attempt ); usleep( random_int( $min, $max ) ); } continue; } if ( $id <= 0 ) { // Failed for some other reason: a missing column mid-upgrade, a // dropped connection. Nothing was recorded. throw new LedgerUnavailable( 'The ledger row could not be stored.' ); } /** * Fires once a ledger row is stored, redacted and chained. * * The Agency network index follows it (AF-22.2e); nothing listens in * the free build. * * @param int $id Ledger row id. * @param array $data The row as stored. */ do_action( 'adaf_ledger_appended', $id, $row ); return $id; } throw new LedgerContention( 'Every attempt to append the ledger row lost to a concurrent append.' ); } /** * Normalize and redact a row before it is hashed. * * 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. * * @param array $data Row data. * @return array */ private function prepare( array $data ): array { unset( $data['chain_hash'], $data['prev_id'] ); 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'] ); } return $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; } /** * The range a pause after a lost attempt is drawn from. * * @param int $attempt Zero-based attempt that was just lost. * @return array{int, int} Minimum and maximum, in microseconds. */ public static function backoff_window( int $attempt ): array { $scale = 1 << min( max( 0, $attempt ), self::BACKOFF_DOUBLINGS ); return array( self::BACKOFF_MIN * $scale, self::BACKOFF_MAX * $scale ); } /** * 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 { return $this->diagnose()?->entry_id; } /** * Walk the whole chain and say where it first fails, and why (AF-24.6). * * The hash decides whether the chain fails; the links rows have carried * since AF-24.5 only say how. Checked first, so a removed row is * reported as a removal rather than as the next row's hash mismatch - * the same row either way. */ public function diagnose(): ?ChainBreak { // 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 new ChainBreak( $row->id, ChainBreak::UNHASHED ); } // Only between two rows this walk has seen: the first row // after a prune follows one retention removed, which the // anchor accounts for. Rows from before AF-24.5 name nothing. $linked = null !== $row->prev_id && $after_id > 0; if ( $linked && $row->prev_id !== $after_id ) { return new ChainBreak( $row->id, $row->prev_id > $after_id ? ChainBreak::REMOVED : ChainBreak::INSERTED, $row->prev_id, $after_id ); } $expected = hash( 'sha256', $previous . self::canonical_payload( $row ) ); if ( ! hash_equals( $expected, $row->chain_hash ) ) { return new ChainBreak( $row->id, ChainBreak::CHANGED, $linked ? $row->prev_id : null ); } $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, ) ); } }