|\WP_Error Execution result, or an error. */ public function approve( int $pending_id, int $decided_by, DateTimeImmutable $now ) { if ( null === $this->ledger || null === $this->executor ) { return new \WP_Error( 'adaf_not_configured', __( 'Approval flow is not wired.', 'agentic-daisy-ai-agent-firewall' ) ); } $pending = $this->pending->find( $pending_id ); $entry = null === $pending ? null : $this->ledger->find( $pending->ledger_id ); if ( null !== $pending && 'pending' === $pending->status && null === $entry ) { return new \WP_Error( 'adaf_exec_failed', __( 'The ledger entry for this action is missing.', 'agentic-daisy-ai-agent-firewall' ) ); } // Checked here, beneath every caller, so no path approves a hold its // approver may not decide (ADR-18 §8). $refusal = null === $pending ? null : $this->may_decide( $pending, $decided_by, true ); if ( null !== $refusal ) { return $refusal; } // The hourly sweep runs on WP-Cron, late on a quiet site: a hold past // its expiry is expired here rather than run (ADR-8 §5). if ( null !== $pending && 'pending' === $pending->status && $pending->expires_at < $now ) { $this->expire( $pending, $now ); return new \WP_Error( 'adaf_expired', __( 'This action expired before it was approved.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 409 ) ); } // Claimed before anything runs: two approvers at once - two people, // or a person and the AI evaluator - must run the action once. Only // one claim matches a pending row; the other finds it decided. if ( null === $pending || null === $entry || ! $this->pending->claim_for_approval( $pending_id, $now ) ) { return new \WP_Error( 'adaf_not_pending', __( 'This action is not awaiting approval.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 409 ) ); } try { return $this->carry_out( $pending, $entry, $decided_by, $now ); } catch ( \Throwable $error ) { // A crash before the action ran must not leave the hold stuck // mid-approval; one during the run was already closed as // interrupted, which this leaves alone. $this->pending->release_approval( $pending_id ); throw $error; } } /** * Run a claimed approval: drift-check, execute as the original user, * and settle or release the claim. * * @param PendingAction $pending The claimed hold. * @param LedgerEntry $entry Its ledger row. * @param int $decided_by Approving WP user. * @param DateTimeImmutable $now Decision time. * @return array|\WP_Error */ private function carry_out( PendingAction $pending, LedgerEntry $entry, int $decided_by, DateTimeImmutable $now ) { assert( null !== $this->ledger && null !== $this->executor ); $pending_id = $pending->id; // Pre-flight state-drift verification (ADR-8): a concurrent human // edit always wins over the queued agent intent. if ( $this->drifted( $pending, $entry ) ) { $this->pending->settle_approval( $pending_id, 'drift_conflict', $decided_by, $now ); $this->ledger->update_status( $entry->id, 'denied' ); // Otherwise the row reads as a policy denial - "a built-in // default applied" - when it was approved and then stopped. $this->ledger->update_result( $entry->id, array( 'denial_reason' => 'drift_conflict' ) + self::ai_review( $entry ) ); return new \WP_Error( 'adaf_drift_conflict', __( 'Target entity was modified after action was queued.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 409 ) ); } // Capture before-state immediately ahead of execution, exactly as an // allowed action gets at interception - otherwise an approved change // has nothing to undo from (AF-18.2). Taken now, not at hold time: // the state being replaced is today's, not the state when it queued. $this->snapshots?->capture( $entry->action( $pending->stored_input ), $entry->id, $now ); // Execute the stored payload as the original linked WP user. $previous_user = get_current_user_id(); wp_set_current_user( $entry->wp_user_id ); try { $result = $this->executor->execute( $entry, $pending ); } catch ( \Throwable $error ) { // It crashed mid-run, so it may have taken effect in part or in // full: closed as interrupted, never requeued to run again // (AF-24.2). The caller's release then finds nothing to release. $this->interrupt( $pending_id, $entry->id ); throw $error; } finally { wp_set_current_user( $previous_user ); } if ( $result instanceof \WP_Error ) { // Nothing changed, so the snapshot describes nothing; a retry // takes a fresh one. $this->snapshots?->discard( $entry->id ); // Refused for permissions, the action is finished: approving it // again runs as the same user and cannot succeed. Anything else // - a validation error, a failed write - goes back to the queue // so the admin can retry or reject. if ( self::is_permission_refusal( $result ) ) { $this->pending->settle_approval( $pending_id, 'refused', $decided_by, $now ); $this->ledger->update_status( $entry->id, 'refused' ); $this->ledger->update_result( $entry->id, array( 'refused_at' => 'permission', 'code' => $result->get_error_code(), 'on_approval' => true, ) + self::ai_review( $entry ) ); } else { $this->pending->release_approval( $pending_id ); } return $result; } $this->pending->settle_approval( $pending_id, 'approved', $decided_by, $now ); $this->ledger->update_status( $entry->id, 'allowed' ); // The record's own keys win over whatever the run returned. $this->ledger->update_result( $entry->id, self::ai_review( $entry ) + $result ); return $result; } /** * Whether the target changed since the action was held. * * An entity provider judges its own type by timestamp and fingerprint; * either differing, or the provider throwing, is drift. A hold that has * a fingerprint but no provider now - its extension was deactivated - * cannot be checked, so it is drift too. Everything else keeps the * core's post timestamp check (ADR-8). * * @param PendingAction $pending The hold. * @param LedgerEntry $entry Its ledger row. */ private function drifted( PendingAction $pending, LedgerEntry $entry ): bool { $provider = $this->extensions?->entity_provider( $entry->entity_type ); if ( null === $provider ) { if ( null !== $pending->initial_fingerprint ) { return true; } if ( null === $pending->initial_entity_modified_at ) { return false; } $current = $this->current_entity_modified( $entry->entity_type, $entry->entity_id ); return null === $current || $current->format( 'Y-m-d H:i:s' ) !== $pending->initial_entity_modified_at->format( 'Y-m-d H:i:s' ); } if ( self::UNREADABLE === $pending->initial_fingerprint ) { return true; } try { $current = self::read( $provider, $entry->action( $pending->stored_input ) ); } catch ( \Throwable $failure ) { return true; } return self::stamp( $current[0] ) !== self::stamp( $pending->initial_entity_modified_at ) || $current[1] !== $pending->initial_fingerprint; } /** * A provider's drift baseline: its timestamp in UTC, and the SHA-256 of * its fingerprint. * * @param EntityProvider $provider The provider. * @param ActionDescriptor $action The action. * @return array{0: DateTimeImmutable|null, 1: string|null} * @throws \Throwable Whatever the provider throws. */ private static function read( EntityProvider $provider, ActionDescriptor $action ): array { $modified = $provider->modified_at( $action ); $fingerprint = $provider->drift_fingerprint( $action ); return array( $modified?->setTimezone( new DateTimeZone( 'UTC' ) ), null === $fingerprint ? null : hash( 'sha256', $fingerprint ), ); } /** * A timestamp as the queue stores it. * * @param DateTimeImmutable|null $time Timestamp. */ private static function stamp( ?DateTimeImmutable $time ): ?string { return $time?->format( 'Y-m-d H:i:s' ); } /** * The AI evaluator's part in a hold, from its ledger row. * * @param LedgerEntry $entry Ledger row. * @return array */ private static function ai_review( LedgerEntry $entry ): array { return array_intersect_key( $entry->result ?? array(), array_flip( self::AI_REVIEW_KEYS ) ); } /** * Close approvals left in flight by a request that died - a fatal * error, a timeout - on the hourly sweep. Ten minutes is far longer than * any approval takes. They are closed as interrupted, never requeued: * the request may have died after the action ran, and approving it again * would run it twice (AF-24.2). * * @param DateTimeImmutable $now Now. * @return int How many were closed. */ public function close_stale_approvals( DateTimeImmutable $now ): int { $closed = $this->pending->close_stale_approvals( $now->modify( '-' . self::STALE_APPROVAL . ' seconds' ) ); foreach ( $closed as $ledger_id ) { // Only while still pending: a slow run that finished between // the two writes has already recorded what it did. $this->ledger?->update_status_from( $ledger_id, 'pending', 'interrupted' ); } return count( $closed ); } /** * Close a claimed approval whose run crashed as interrupted, with its * ledger row. * * @param int $pending_id Pending row id. * @param int $ledger_id Its ledger row id. */ private function interrupt( int $pending_id, int $ledger_id ): void { if ( $this->pending->interrupt_approval( $pending_id ) ) { $this->ledger?->update_status_from( $ledger_id, 'pending', 'interrupted' ); } } /** * Why approving this action cannot run it, or null when it can - the * executor's own rules, for the approval queue to show (AF-18.2). * * @param PendingAction $action Held action. */ public function why_not_executable( PendingAction $action ): ?string { return $this->review( $action )['reason']; } /** * What the approval queue shows about one held action: the ledger row * that names its target (AF-18.6), and why approving cannot run it, or * null when it can (AF-18.2). One ledger read serves both. * * @param PendingAction $action Held action. * @return array{entry: \AgentFirewall\Model\LedgerEntry|null, reason: string|null} */ public function review( PendingAction $action ): array { if ( null === $this->ledger || null === $this->executor ) { return array( 'entry' => null, 'reason' => null, ); } $entry = $this->ledger->find( $action->ledger_id ); if ( null === $entry ) { return array( 'entry' => null, 'reason' => __( 'The ledger entry for this action is missing.', 'agentic-daisy-ai-agent-firewall' ), ); } return array( 'entry' => $entry, 'reason' => $this->executor->why_not( $entry, $action ), ); } /** * Whether a failed execution was core refusing the linked user - an * authentication or authorization failure, which retrying cannot fix. * * @param \WP_Error $error Execution error. */ private static function is_permission_refusal( \WP_Error $error ): bool { $data = $error->get_error_data(); return is_array( $data ) && isset( $data['status'] ) && in_array( (int) $data['status'], array( 401, 403 ), true ); } /** * Reject a pending action. * * @param int $pending_id Pending row id. * @param int $decided_by Rejecting WP user. * @param DateTimeImmutable $now Decision time. * @return bool Whether the record transitioned. */ public function reject( int $pending_id, int $decided_by, DateTimeImmutable $now ): bool { $pending = $this->pending->find( $pending_id ); if ( null === $pending || null !== $this->may_decide( $pending, $decided_by, false ) ) { return false; } $transitioned = $this->pending->transition( $pending_id, 'rejected', $decided_by, $now ); if ( $transitioned && null !== $this->ledger ) { $this->ledger->update_status( $pending->ledger_id, 'denied' ); } return $transitioned; } /** * Expire every overdue pending action and sync its ledger row * (fail-closed: ancient intents are never executed, ADR-8 §5). * * @param DateTimeImmutable|null $now Reference time. * @return int Number of actions expired. */ public function expire_overdue( ?DateTimeImmutable $now = null ): int { $now = $now ?? new DateTimeImmutable( 'now' ); $count = 0; foreach ( $this->pending->list_overdue( $now ) as $overdue ) { if ( $this->expire( $overdue, $now ) ) { ++$count; } } return $count; } /** * Expire one pending action and sync its ledger row. * * @param PendingAction $pending Overdue action. * @param DateTimeImmutable $now Expiry time. * @return bool Whether this call expired it. */ private function expire( PendingAction $pending, DateTimeImmutable $now ): bool { if ( ! $this->pending->transition( $pending->id, 'expired', null, $now ) ) { return false; } if ( null !== $this->ledger ) { $this->ledger->update_status( $pending->ledger_id, 'denied' ); } return true; } /** * Current modification timestamp of an entity by type/id. * * @param string $entity_type Entity type. * @param int|null $entity_id Entity id. */ private function current_entity_modified( string $entity_type, ?int $entity_id ): ?DateTimeImmutable { if ( null === $entity_id || ! in_array( $entity_type, self::POST_ENTITIES, true ) ) { return null; } $post = get_post( $entity_id ); if ( ! $post instanceof \WP_Post || '' === $post->post_modified_gmt ) { return null; } return new DateTimeImmutable( $post->post_modified_gmt, new DateTimeZone( 'UTC' ) ); } /** * Queue an action for approval. * * @param Agent $agent Acting agent. * @param ActionDescriptor $action Normalized action. * @param int $ledger_id Ledger row already written for this action. * @param DateTimeImmutable $now Reference time. * @param string|null $classified_by Extension whose classifier retyped the * action, if any: another extension's claim * on the new type does not apply. */ public function queue( Agent $agent, ActionDescriptor $action, int $ledger_id, DateTimeImmutable $now, ?string $classified_by = null ): PendingAction { $ttl = get_option( 'adaf_pending_ttl', self::DEFAULT_TTL ); $ttl = is_numeric( $ttl ) ? (int) $ttl : self::DEFAULT_TTL; list( $modified, $fingerprint ) = $this->baseline( $action ); // Who may decide is settled now, from the facts the hold was decided // on, and stored so the queue can filter on it in SQL (AF-25.8). $requirement = null === $this->extensions ? array( 'capability' => 'manage_options', 'admin' => false, ) : $this->extensions->approval_requirement( $action, $classified_by ); return $this->pending->create( array( 'ledger_id' => $ledger_id, 'agent_id' => $agent->id, 'ability_name' => $action->ability_name ?? ( 'rest:' . $action->action_cat . ':' . $action->entity_type ), 'stored_input' => $action->input, 'request' => $action->replay, 'initial_entity_modified_at' => $modified, 'initial_fingerprint' => $fingerprint, 'expires_at' => $now->modify( sprintf( '+%d seconds', $ttl ) ), 'required_cap' => $requirement['capability'], 'requires_admin' => $requirement['admin'], ) ); } /** * Why this user may not decide this hold, or null when they may * (AF-25.8, ADR-18 §8). * * They need the hold's capability, and manage_options as well when an * extension's claim on a core type set it. Approving is further closed * to the agent's own linked user: always, for a hold an extension * claimed below manage_options - shop managers are exactly the accounts * agents are linked to - and otherwise when the site turned the ban on. * Rejecting stays open to them: it can only stop an action. The AI * evaluator (user 0) decides through its own gate (ADR-15). * * @param PendingAction $pending The hold. * @param int $user_id Deciding user; 0 for the AI evaluator. * @param bool $approving Approving rather than rejecting. */ public function may_decide( PendingAction $pending, int $user_id, bool $approving ): ?\WP_Error { if ( 0 === $user_id ) { return null; } if ( ! $this->entitled( $pending, $user_id ) ) { return new \WP_Error( 'adaf_hold_forbidden', $pending->requires_admin ? sprintf( /* translators: %s: capability name. */ __( 'Deciding this action needs the manage_options and %s capabilities.', 'agentic-daisy-ai-agent-firewall' ), $pending->required_cap ) : sprintf( /* translators: %s: capability name. */ __( 'Deciding this action needs the %s capability.', 'agentic-daisy-ai-agent-firewall' ), $pending->required_cap ), array( 'status' => 403 ) ); } $claimed_below = 'manage_options' !== $pending->required_cap && ! $pending->requires_admin; $banned = $claimed_below || '1' === (string) get_option( self::OPTION_BAN_SELF_APPROVAL, '0' ); // A linked user that cannot be read is taken to be this one: the // ban fails closed. $linked = $this->ledger?->find( $pending->ledger_id )?->wp_user_id; if ( $approving && $banned && ( null === $linked || $user_id === $linked ) ) { return new \WP_Error( 'adaf_self_approval', __( 'This action was held for the agent linked to your account, so someone else must approve it.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 403 ) ); } return null; } /** * Whether this user holds what the hold asks for. * * An administrator decides any hold on an extension's own type, so * holds never strand when the claim's capability is one no * administrator holds, or its extension is gone. A claim on a core type * needs its capability as well - unless the claim is gone with its * extension, leaving manage_options alone. * * @param PendingAction $pending The hold. * @param int $user_id Deciding user. */ private function entitled( PendingAction $pending, int $user_id ): bool { $admin = user_can( $user_id, 'manage_options' ); if ( ! $pending->requires_admin ) { return $admin || user_can( $user_id, $pending->required_cap ); } if ( ! $admin ) { return false; } $type = $this->ledger?->find( $pending->ledger_id )?->entity_type; return user_can( $user_id, $pending->required_cap ) || ( null !== $type && null !== $this->extensions && ! $this->extensions->claims( $type ) ); } /** * What approval will compare against: the target's timestamp, and an * entity provider's fingerprint hash. A provider that throws leaves no * baseline, recorded so that approval sees drift. * * @param ActionDescriptor $action Normalized action. * @return array{0: DateTimeImmutable|null, 1: string|null} */ private function baseline( ActionDescriptor $action ): array { $provider = $this->extensions?->entity_provider( $action->entity_type ); if ( null === $provider ) { return array( $this->current_entity_modified( $action->entity_type, $action->entity_id ), null ); } try { return self::read( $provider, $action->as_stored() ); } catch ( \Throwable $failure ) { return array( null, self::UNREADABLE ); } } }