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; /** * 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; } /** * 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. */ public function process( Agent $agent, ActionDescriptor $action, ?DateTimeImmutable $now = null, bool $queueable = true ): 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; // 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, $action, $now, $queueable, $label, &$decision ): array { $decision = $this->decide( $agent, $action, $now, $queueable ); return $this->row( $agent, $action, $now, $label, $decision[0], $decision[1] ); } ); } 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 ); } /** @var array{0: Evaluation, 1: bool} $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 )->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: 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, ); } /** * 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; } // 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(); } }