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; // Kill switch outranks everything: while engaged, every agent // action — reads included — is denied before scope or policy runs. if ( $this->lockdown->engaged() ) { $evaluation = new \AgentFirewall\Policy\Evaluation( Decision::DENY, \AgentFirewall\Policy\Evaluation::REASON_LOCKDOWN ); } elseif ( ! ScopeGate::permits( $agent, $action ) ) { $evaluation = new \AgentFirewall\Policy\Evaluation( Decision::DENY, \AgentFirewall\Policy\Evaluation::REASON_OUT_OF_SCOPE ); } else { $evaluation = $this->evaluator->evaluate_action( $agent->id, $action, $now ); } if ( ! $queueable && Decision::REQUIRE_APPROVAL === $evaluation->decision ) { $evaluation = new \AgentFirewall\Policy\Evaluation( Decision::DENY, \AgentFirewall\Policy\Evaluation::REASON_APPROVAL_UNAVAILABLE, $evaluation->policy_id, $evaluation->rule_index ); } $decision = $evaluation->decision; $ledger_id = $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, // Named now, while the target still exists. A completed // deletion cannot be looked up afterwards, so a row that only // stored the id would be permanently unreadable (AF-12.10). 'entity_label' => $this->labeler?->label( $action ), 'ability_name' => $action->ability_name, 'status' => $this->ledger_status( $decision ), 'input' => $action->input, 'result' => null === $evaluation->reason ? null : array( 'denial_reason' => $evaluation->reason ), // 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, ) ); $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. */ do_action( 'adaf_action_pending', $pending_id, $agent, $action, $ledger_id ); } 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 ); } /** * 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'; } } }