lockdown = $lockdown ?? new \AgentFirewall\Policy\Lockdown(); } /** * Whether any evaluation ran this request. The effect backstop * (AF-13.3) consults this so a write classified at a transport is never * evaluated again at effect - one ledger row for one agent write, * however many hooks it passes through. */ private static bool $request_evaluated = false; /** * What this request recorded, for each agent: its first ledger row - * the door's, which an independent effect inside it names - and the * targets it decided (AF-25.9). Keyed by agent, so a process serving * several never ties one agent's effect to another's request. * * @var array}> */ private static array $recorded = array(); /** * The first ledger row this request recorded for an agent, if any. * * @param int $agent_id Agent. */ public static function first_ledger_id( int $agent_id ): ?int { return self::$recorded[ $agent_id ]['row'] ?? null; } /** * What this request decided for an agent: entity type, category and id * of each action recorded. * * @param int $agent_id Agent. * @return list */ public static function decided_targets( int $agent_id ): array { return self::$recorded[ $agent_id ]['targets'] ?? array(); } /** * Whether an evaluation already ran during this request. */ public static function request_already_evaluated(): bool { return self::$request_evaluated; } /** * Stamp the per-request marker. process() does this itself; the only * other legitimate caller is a test simulating a classified transport. */ public static function mark_request_evaluated(): void { self::$request_evaluated = true; } /** * Clear the marker (tests only; a real request resets naturally). */ public static function reset_request_marker(): void { self::$request_evaluated = false; self::$recorded = array(); } /** * Run an agent action through policy, ledger, and queue. * * @param Agent $agent Acting agent. * @param ActionDescriptor $action Normalized action. * @param DateTimeImmutable $now Reference time (tests inject). * @param bool $queueable Whether REQUIRE_APPROVAL can queue. * False at effect hooks, where the * write is already in flight and * cannot be held for re-execution; * approval degrades to denial there. * @param int|null $inside Ledger row of the request this * action happened inside, noted on * its row (AF-25.9). * @param bool $stated Whether an extension built the * descriptor itself - an effect it * holds - so its type is the * extension's statement, not the * core's guess (AF-25.15). */ public function process( Agent $agent, ActionDescriptor $action, ?DateTimeImmutable $now = null, bool $queueable = true, ?int $inside = null, bool $stated = false ): InterceptionOutcome { $now = $now ?? new DateTimeImmutable( 'now' ); // Stamped before evaluating so writes the pipeline itself performs // (throttle transients, notifier options) cannot re-enter the // backstop mid-evaluation. self::$request_evaluated = true; // Extensions refine the action once, outside the append, which may // decide several times (ADR-18 §2). From here on the refined action // is the one recorded, queued and announced; the raw one still // takes part in the decision. $refinement = $this->refiner?->refine( $action ); $raw = null === $refinement ? $action : $refinement->raw; $action = null === $refinement ? $action : $refinement->action; // Named now, while the target still exists. A completed deletion // cannot be looked up afterward, so a row that only stored the id // would be permanently unreadable (AF-12.10). $label = $this->labeler?->label( $action ); $decision = null; try { // Decided inside the append, after it reads the ledger's head: an // attempt that loses to a concurrent write decides again, so the // rate limit counts every row before this one (ADR-17). $ledger_id = $this->writer->append( function () use ( $agent, $raw, $refinement, $now, $queueable, $label, $inside, $stated, &$decision ): array { $decision = $this->decide_refined( $agent, $raw, $refinement, $now, $queueable, $stated ); $row = $this->row( $agent, null === $refinement ? $raw : $refinement->action, $now, $label, $decision[0], $decision[1] ); $notes = $decision[2]; if ( null !== $refinement && array() !== $refinement->slow ) { $notes['slow_fact_providers'] = $refinement->slow; } if ( array() !== $notes ) { $row['result'] = array_merge( $row['result'] ?? array(), array( 'extensions' => $notes ) ); } if ( null !== $inside ) { $row['result'] = array_merge( $row['result'] ?? array(), array( 'inside_ledger_id' => $inside ) ); } return $row; } ); } catch ( LedgerUnavailable $busy ) { // Nothing was recorded, so nothing may run. Retryable: the // contention that caused it passes in moments. return new InterceptionOutcome( Decision::DENY, 0, null, Evaluation::REASON_LEDGER_BUSY, 1 ); } self::$recorded[ $agent->id ] = self::$recorded[ $agent->id ] ?? array( 'row' => $ledger_id, 'targets' => array(), ); self::$recorded[ $agent->id ]['targets'][] = array( $action->entity_type, $action->action_cat, $action->entity_id ); // A batch decided each of its items, so an effect hook meeting one // of those writes knows the door covered it (AF-25.11). // Only items naming their target: an item with no id would match any // effect of its type and category, and hide an independent one. foreach ( null === $refinement ? array() : $refinement->items as $item ) { if ( null !== $item->action->entity_id ) { self::$recorded[ $agent->id ]['targets'][] = array( $item->action->entity_type, $item->action->action_cat, $item->action->entity_id ); } } /** @var array{0: Evaluation, 1: bool, 2: array} $decision Set by the append's last attempt. */ list( $evaluation, $awaits_ai ) = $decision; $decision = $evaluation->decision; $this->evaluator->raise_alerts( $agent->id, $evaluation ); $pending_id = null; if ( Decision::REQUIRE_APPROVAL === $decision ) { $pending_id = $this->pending->queue( $agent, $action, $ledger_id, $now, $refinement?->refined_by )->id; /** * Fires when an action is held for human approval. * * Announced after the ledger row and the pending row exist, so a * listener can reference both. This is the outcome with the * shortest fuse in the plugin - the agent has its 202 and the work * is frozen until a person acts - and until AF-16.2 nothing told * anyone it had happened. * * @param int $pending_id Pending row id. * @param Agent $agent Agent whose action was held. * @param ActionDescriptor $action The held action. * @param int $ledger_id Ledger row recording the decision. * @param bool $awaits_ai Whether the AI evaluator reviews it * first. Notifications wait when true: * an escalation fires this hook again, * with false, for a human (ADR-15 §5). */ do_action( 'adaf_action_pending', $pending_id, $agent, $action, $ledger_id, $awaits_ai ); } if ( Decision::DENY === $decision ) { /** * Fires when an action is refused. * * Carries the reason, because most denials are the firewall doing * its job and only some are worth waking anyone for; a listener * that could not tell them apart would have to send all or none. * * @param Agent $agent Agent that was refused. * @param ActionDescriptor $action What it tried to do. * @param string|null $reason Denial reason, an Evaluation::REASON_* value. * @param int $ledger_id Ledger row recording the decision. */ do_action( 'adaf_action_denied', $agent, $action, $evaluation->reason, $ledger_id ); } // Capture before-state ahead of execution so the action can be // rolled back later (ADR-9). Only for actions about to execute. if ( null !== $this->snapshots && 'read' !== $action->action_cat && ( Decision::ALLOW === $decision || Decision::LOG_ONLY === $decision ) ) { $this->snapshots->capture( $action, $ledger_id, $now ); } return new InterceptionOutcome( $decision, $ledger_id, $pending_id, $evaluation->reason, $evaluation->retry_after ); } /** * Decide an action an extension may have refined (ADR-18 §2). * * The raw and the refined descriptor are both decided, and the stricter * decision wins, so no classification can loosen a decision - only a * user rule can. A refinement that would have is noted with the * extension's id. An extension that failed leaves the raw descriptor, * whose decision is raised to at least a hold; where nothing can be * held, decide() turns that into a denial as it does any hold. * * @param Agent $agent Acting agent. * @param ActionDescriptor $raw The core's descriptor. * @param \AgentFirewall\Extension\Refinement|null $refinement What the extensions made of it. * @param DateTimeImmutable $now Reference time. * @param bool $queueable Whether REQUIRE_APPROVAL can queue. * @param bool $stated Whether an extension built the descriptor. * @return array{0: Evaluation, 1: bool, 2: array} The * evaluation, whether an AI reviewer looks first, and the notes * for the row's result. */ private function decide_refined( Agent $agent, ActionDescriptor $raw, ?\AgentFirewall\Extension\Refinement $refinement, DateTimeImmutable $now, bool $queueable, bool $stated = false ): array { // A batch is its items, each guarded on its own below; its envelope // says nothing of its own for the admin-agent guard to doubt. $envelope = $stated || ( null !== $refinement && array() !== $refinement->items ); list( $evaluation, $awaits_ai, $notes ) = $this->decide_one( $agent, $raw, $refinement, $now, $queueable, $envelope ); if ( null === $refinement || array() === $refinement->items ) { return array( $evaluation, $awaits_ai, $notes ); } // A batch (AF-25.11): each item is decided as the request it stands // for, and the strictest decision - the batch's own or an item's - // decides the whole request. The row lists the items that were not // allowed, so a held or denied batch says which writes did it. $flagged = array(); foreach ( $refinement->items as $index => $item ) { list( $item_evaluation, $item_ai ) = $this->decide_one( $agent, $item->raw, $item, $now, $queueable ); if ( 0 === self::strictness( $item_evaluation->decision ) ) { continue; } $flagged[] = array( 'index' => $index, 'entity_type' => $item->action->entity_type, 'action_cat' => $item->action->action_cat, 'entity_id' => $item->action->entity_id, 'decision' => $item_evaluation->decision->value, ); if ( self::outranks( $item_evaluation, $evaluation ) ) { $evaluation = $item_evaluation; $awaits_ai = $item_ai; } } if ( array() !== $flagged ) { $notes['batch_items'] = $flagged; } return array( $evaluation, $awaits_ai, $notes ); } /** * Decide one action an extension may have refined: the stricter of its * raw and refined descriptors (ADR-18 §2), then, for an * administrator-run agent, the guard (ADR-18 §9). * * @param Agent $agent Acting agent. * @param ActionDescriptor $raw The core's descriptor. * @param \AgentFirewall\Extension\Refinement|null $refinement What the extensions made of it. * @param DateTimeImmutable $now Reference time. * @param bool $queueable Whether REQUIRE_APPROVAL can queue. * @param bool $stated Whether an extension built the descriptor. * @return array{0: Evaluation, 1: bool, 2: array} */ private function decide_one( Agent $agent, ActionDescriptor $raw, ?\AgentFirewall\Extension\Refinement $refinement, DateTimeImmutable $now, bool $queueable, bool $stated = false ): array { list( $evaluation, $awaits_ai, $notes ) = $this->combine( $agent, $raw, $refinement, $now, $queueable ); // An agent whose user holds manage_options is held back by nothing // WordPress checks, so the firewall's own guard stands in (AF-25.15). // After the combination, so no rule on either reading loosens it; // checked per decision, so an agent whose user was promoted since it // was linked is guarded too. if ( null === $this->guard || ! user_can( $agent->wp_user_id, 'manage_options' ) ) { return array( $evaluation, $awaits_ai, $notes ); } $typed = null !== $refinement && null !== $refinement->refined_by && array() === $refinement->failed ? $refinement->action : null; if ( null === $typed && $stated ) { $typed = $raw; } $guarded = $this->guard->apply( $raw, $typed, $evaluation ); if ( $guarded === $evaluation ) { return array( $evaluation, $awaits_ai, $notes ); } if ( ! $queueable && Decision::REQUIRE_APPROVAL === $guarded->decision ) { $guarded = new Evaluation( Decision::DENY, Evaluation::REASON_APPROVAL_UNAVAILABLE, $guarded->policy_id, $guarded->rule_index, null, false, $guarded->network, null, false, false, $guarded->extension, true ); } return array( $guarded, false, $notes ); } /** * The stricter of an action's raw and refined decisions (ADR-18 §2). * * @param Agent $agent Acting agent. * @param ActionDescriptor $raw The core's descriptor. * @param \AgentFirewall\Extension\Refinement|null $refinement What the extensions made of it. * @param DateTimeImmutable $now Reference time. * @param bool $queueable Whether REQUIRE_APPROVAL can queue. * @return array{0: Evaluation, 1: bool, 2: array} */ private function combine( Agent $agent, ActionDescriptor $raw, ?\AgentFirewall\Extension\Refinement $refinement, DateTimeImmutable $now, bool $queueable ): array { list( $evaluation, $awaits_ai ) = $this->decide( $agent, $raw, $now, $queueable ); if ( null === $refinement || ! $refinement->changed() ) { return array( $evaluation, $awaits_ai, array() ); } if ( array() !== $refinement->failed ) { $notes = array( 'extension_failed' => $refinement->failed ); if ( self::strictness( $evaluation->decision ) >= self::strictness( Decision::REQUIRE_APPROVAL ) ) { return array( $evaluation, $awaits_ai, $notes ); } $held = $queueable ? new Evaluation( Decision::REQUIRE_APPROVAL, null, $evaluation->policy_id, $evaluation->rule_index ) : new Evaluation( Decision::DENY, Evaluation::REASON_APPROVAL_UNAVAILABLE, $evaluation->policy_id, $evaluation->rule_index ); return array( $held, false, $notes ); } if ( null === $refinement->refined_by ) { return array( $evaluation, $awaits_ai, array() ); } list( $refined, $refined_ai ) = $this->decide( $agent, $refinement->action, $now, $queueable ); if ( self::owner_decides( $raw, $evaluation, $refinement->action, $refined ) ) { return array( $refined, $refined_ai, array() ); } if ( ! self::outranks( $evaluation, $refined ) ) { return array( $refined, $refined_ai, array() ); } // Equally strict, but the raw decision wants a person where the // refined one would let the AI evaluator review: that is routing, // not a loosening, so nothing is noted. if ( self::strictness( $evaluation->decision ) === self::strictness( $refined->decision ) ) { return array( $evaluation, $awaits_ai, array() ); } return array( $evaluation, $awaits_ai, array( 'classifier_violation' => $refinement->refined_by ) ); } /** * Whether a store owner's rule on the typed action decides, over the * built-in defaults the core's reading fell to (ADR-18: only a user * rule can loosen). "Allow order deletes" must let an order delete * through, though the core alone sees an unknown delete, which the * defaults hold. * * Only the defaults give way: the raw evaluation matched no rule and * carries no reason - no kill switch, scope, limit or rule denial - and * the refined one was decided by a stored or network rule, on the * category the core read, and into a type an extension owns - never a * core type, which a classifier may give an unknown action - so a * classification cannot steer an action into a rule written for * another kind of write. * * @param ActionDescriptor $raw The core's descriptor. * @param Evaluation $raw_eval Its evaluation. * @param ActionDescriptor $refined The typed descriptor. * @param Evaluation $ruled Its evaluation. */ private static function owner_decides( ActionDescriptor $raw, Evaluation $raw_eval, ActionDescriptor $refined, Evaluation $ruled ): bool { $raw_defaulted = ! $raw_eval->matched_a_rule() && ! $raw_eval->network && null === $raw_eval->extension && null === $raw_eval->reason && Decision::DENY !== $raw_eval->decision; return $raw_defaulted && ( $ruled->matched_a_rule() || $ruled->network ) && $raw->action_cat === $refined->action_cat && ! in_array( $refined->entity_type, \AgentFirewall\Policy\RuleSchema::CORE_ENTITY_TYPES, true ); } /** * Whether one evaluation is stricter than another. A hold that needs a * person outranks one routed to the AI evaluator (EVALUATE_AI): of two * equal holds, the one the AI may approve is the looser. * * @param Evaluation $candidate The evaluation that might win. * @param Evaluation $current The one it is compared with. */ private static function outranks( Evaluation $candidate, Evaluation $current ): bool { $candidate_rank = self::strictness( $candidate->decision ); $current_rank = self::strictness( $current->decision ); if ( $candidate_rank !== $current_rank ) { return $candidate_rank > $current_rank; } return 1 === $candidate_rank && $current->ai_triage && ! $candidate->ai_triage; } /** * How strict a decision is. Allow and log-only both let the action run, * so moving between them is precision, not a loosening. Anything else * ranks as a hold. * * @param Decision $decision A decision. */ private static function strictness( Decision $decision ): int { switch ( $decision ) { case Decision::DENY: return 2; case Decision::ALLOW: case Decision::LOG_ONLY: return 0; default: return 1; } } /** * Decide an action: kill switch, then scope, then policy. * * @param Agent $agent Acting agent. * @param ActionDescriptor $action Normalized action. * @param DateTimeImmutable $now Reference time. * @param bool $queueable Whether REQUIRE_APPROVAL can queue. * @return array{0: Evaluation, 1: bool} The evaluation, and whether an AI * reviewer looks at the hold first. */ private function decide( Agent $agent, ActionDescriptor $action, DateTimeImmutable $now, bool $queueable ): array { // Kill switch outranks everything: while engaged, every agent // action, reads included, is denied before scope or policy runs. if ( $this->lockdown->engaged() ) { $evaluation = new Evaluation( Decision::DENY, Evaluation::REASON_LOCKDOWN ); } elseif ( ! ScopeGate::permits( $agent, $action ) ) { $evaluation = new Evaluation( Decision::DENY, Evaluation::REASON_OUT_OF_SCOPE ); } else { $evaluation = $this->evaluator->decide( $agent->id, $action, $now ); } if ( ! $queueable && Decision::REQUIRE_APPROVAL === $evaluation->decision ) { $evaluation = new Evaluation( Decision::DENY, Evaluation::REASON_APPROVAL_UNAVAILABLE, $evaluation->policy_id, $evaluation->rule_index ); } /** * Filters whether an AI reviewer will look at AI-routed holds before * a human is told about them (ADR-15 §5). * * Nothing hooks it in the free build, so every hold is announced at * once; the Pro evaluator answers true while it is available and * switched on. Asked only for holds an EVALUATE_AI rule made, and * decided before the ledger row is written so the row records it. * * @param bool $active Whether an AI reviewer is active. Default false. */ $awaits_ai = Decision::REQUIRE_APPROVAL === $evaluation->decision && $evaluation->ai_triage && true === apply_filters( 'adaf_ai_review_active', false ); return array( $evaluation, $awaits_ai ); } /** * The ledger row recording a decision. * * @param Agent $agent Acting agent. * @param ActionDescriptor $action Normalized action. * @param DateTimeImmutable $now Reference time. * @param string|null $label The target's name. * @param Evaluation $evaluation The decision. * @param bool $awaits_ai Whether an AI reviewer looks first. * @return array */ private function row( Agent $agent, ActionDescriptor $action, DateTimeImmutable $now, ?string $label, Evaluation $evaluation, bool $awaits_ai ): array { return array( 'ts' => $now, 'agent_id' => $agent->id, 'wp_user_id' => $agent->wp_user_id, 'source' => $action->source, 'action_cat' => $action->action_cat, 'entity_type' => $action->entity_type, 'entity_id' => $action->entity_id, 'entity_label' => $label, 'ability_name' => $action->ability_name, 'status' => $this->ledger_status( $evaluation->decision ), 'input' => $action->input, 'result' => $this->initial_result( $evaluation, $awaits_ai ), // Which rule decided, so the audit trail answers "why" and not // only "what". Null when the built-in defaults applied. 'policy_id' => $evaluation->policy_id, 'rule_index' => $evaluation->rule_index, ) + ( // Inside the hash when present (AF-25.2); a row without facts // is the row it always was. array() === $action->facts ? array() : array( 'facts' => $action->facts ) ); } /** * Ledger an agent attempt core refused before it could be evaluated * (ADR-13): status "refused", with the stage and core's code, and no * rule credited, because none was consulted. Mirrors what * AbilityRejectionRecorder writes for the ability door. Refused rows * are not counted against the rate limit, so they are capped per agent * instead (RefusalThrottle). * * @param Agent $agent Acting agent. * @param ActionDescriptor $action What it attempted. * @param string $stage input|permission. * @param string $code Core's error code. * @return int|null Ledger row id, or null when the cap skipped it. */ public function record_refusal( Agent $agent, ActionDescriptor $action, string $stage, string $code ): ?int { $now = new DateTimeImmutable( 'now' ); $note = null === $this->refusals ? array() : $this->refusals->admit( $agent->id, $action->entity_type, $now ); if ( null === $note ) { return null; } try { return $this->writer->append( array( 'ts' => $now, 'agent_id' => $agent->id, 'wp_user_id' => $agent->wp_user_id, 'source' => $action->source, 'action_cat' => $action->action_cat, 'entity_type' => $action->entity_type, 'entity_id' => $action->entity_id, 'entity_label' => $this->labeler?->label( $action ), 'ability_name' => $action->ability_name, 'status' => 'refused', 'input' => $action->input, 'result' => array( 'refused_at' => $stage, 'code' => $code, ) + $note, 'policy_id' => null, 'rule_index' => null, ) ); } catch ( LedgerUnavailable $busy ) { // The attempt was refused anyway; losing its record under // contention is the cap's outcome, not a reason to fail the // refusal itself. return null; } } /** * Ledger an agent write that another filter answered before the firewall * could evaluate it (ADR-13): status "bypassed", no rule credited. Nothing * can stop a plugin tying the firewall's priority on * rest_dispatch_request; this at least keeps the write from passing * unseen. It counts against the rate limit, because it probably happened. * * @param Agent $agent Acting agent. * @param ActionDescriptor $action What it attempted. * @return int Ledger row id; 0 when the ledger was too busy to record it. */ public function record_bypass( Agent $agent, ActionDescriptor $action ): int { try { return $this->writer->append( array( 'ts' => new DateTimeImmutable( 'now' ), 'agent_id' => $agent->id, 'wp_user_id' => $agent->wp_user_id, 'source' => $action->source, 'action_cat' => $action->action_cat, 'entity_type' => $action->entity_type, 'entity_id' => $action->entity_id, 'entity_label' => $this->labeler?->label( $action ), 'ability_name' => $action->ability_name, 'status' => 'bypassed', 'input' => $action->input, 'result' => array( 'bypassed_at' => 'rest_dispatch_request' ), 'policy_id' => null, 'rule_index' => null, ) ); } catch ( LedgerUnavailable $busy ) { // Another plugin already answered; failing this request now would // not undo that, so the bypass goes unrecorded. return 0; } } /** * Record an execution result onto the ledger row. * * @param int $ledger_id Ledger row id. * @param array $result Result payload. */ public function record_result( int $ledger_id, array $result ): void { if ( null !== $this->ledger ) { $this->ledger->update_result( $ledger_id, $result ); } } /** * Map a decision to its ledger status. * * @param Decision $decision Policy decision. */ private function ledger_status( Decision $decision ): string { switch ( $decision ) { case Decision::DENY: return 'denied'; case Decision::REQUIRE_APPROVAL: return 'pending'; default: return 'allowed'; } } /** * The result a ledger row starts with: why it was denied, or that an * EVALUATE_AI rule routed its hold to the AI evaluator and whether the * evaluator is reviewing it before a human is told. A hold announced at * once is never later sent to the evaluator, so nobody is told twice. * * @param \AgentFirewall\Policy\Evaluation $evaluation The evaluation. * @param bool $awaits_ai Whether the evaluator reviews it first. * @return array|null */ private function initial_result( \AgentFirewall\Policy\Evaluation $evaluation, bool $awaits_ai ): ?array { $result = array(); if ( null !== $evaluation->reason ) { $result['denial_reason'] = $evaluation->reason; } elseif ( $evaluation->ai_triage && Decision::REQUIRE_APPROVAL === $evaluation->decision ) { $result['routed_to'] = 'ai_evaluator'; $result['awaits_ai'] = $awaits_ai; } // The guard on administrator-run agents decided (AF-25.15): without // this, a hold it raised would read as the rule that allowed it. if ( $evaluation->admin_guard ) { $result['admin_agent'] = true; } // A budget is why (ADR-18 §6): a hold says so, as a denial's // reason does. if ( $evaluation->over_budget ) { $result['over_budget'] = true; } // An extension's default rule decided (AF-25.10): the row has no // policy, and without this it would read as the built-in defaults. if ( null !== $evaluation->extension ) { $result['extension_default'] = $evaluation->extension; } // A network rule decided (ADR-16 §2): the row has no site policy, // and without this it would read as the built-in defaults. if ( $evaluation->network ) { $result['network'] = true; } return array() === $result ? null : $result; } /** * Whether the kill switch - the site's or the network's - is engaged, * so a door that does not otherwise evaluate a request (REST reads) * knows to send it here to be refused and recorded. */ public function locked_down(): bool { return $this->lockdown->engaged(); } }