namespace = 'agenticdaisy-firewall/v1'; $this->rest_base = 'policies'; } /** * Register routes. */ public function register_routes(): void { $rules_schema = RuleSchema::json_schema(); $body_args = array( // Validation happens in two layers. Core enforces this item schema // during sanitization and reports precise paths // ("rules[0][decision] is not one of ..."), rejecting with // rest_invalid_param. Whatever survives that is structurally sound // but may still be semantically wrong, so validated_rules() then // applies the checks a schema cannot express - unreachable rules, // and decision-or-limit - and rejects with adaf_invalid_rules. 'rules' => array( 'type' => 'array', 'items' => $rules_schema['items'], 'description' => $rules_schema['description'], 'required' => true, 'validate_callback' => 'rest_validate_request_arg', ), 'priority' => array( 'type' => 'integer', 'default' => 10, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'agent_id' => array( 'type' => array( 'integer', 'null' ), 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'permissions_check' ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => $body_args, ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/schema', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_rule_schema' ), 'permission_callback' => array( $this, 'permissions_check' ), ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/usage', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_rule_usage' ), 'permission_callback' => array( $this, 'permissions_check' ), ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/simulate', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'simulate' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => array( 'action_cat' => array( 'type' => 'string', 'default' => 'update', 'enum' => RuleSchema::ACTION_CATS, 'validate_callback' => 'rest_validate_request_arg', ), 'entity_type' => array( 'type' => 'string', 'default' => 'post', 'pattern' => '^[a-z][a-z0-9_]*$', 'validate_callback' => 'rest_validate_request_arg', ), 'entity_id' => array( 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'ability' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), 'source' => array( 'type' => 'string', 'default' => 'rest', 'enum' => RuleSchema::SOURCES, 'validate_callback' => 'rest_validate_request_arg', ), 'destructive' => array( 'type' => 'boolean', 'validate_callback' => 'rest_validate_request_arg', ), 'agent_id' => array( 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'facts' => array( 'description' => 'Facts the action carries, by name, as an extension would compute them.', 'type' => 'object', 'validate_callback' => array( self::class, 'valid_simulated_facts' ), ), ), ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/export', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'export_policies' ), 'permission_callback' => array( $this, 'permissions_check' ), ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/import', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'import_policies' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => array( 'format' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), 'version' => array( 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'policies' => array( 'type' => 'array', 'required' => true, 'validate_callback' => 'rest_validate_request_arg', ), ), ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/(?P\d+)', array( array( 'methods' => 'PUT', 'callback' => array( $this, 'update_item' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => $body_args, ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => array( $this, 'permissions_check' ), ), ) ); } /** * Admin capability gate; never __return_true (checklist §1.14). * * @param WP_REST_Request $request Request. * @return true|\WP_Error */ public function permissions_check( $request ) { if ( current_user_can( 'manage_options' ) ) { return true; } return new \WP_Error( 'rest_forbidden', __( 'You are not allowed to manage firewall policies.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => rest_authorization_required_code() ) ); } /** * List policies. * * @param WP_REST_Request $request Request. */ public function get_items( $request ): WP_REST_Response { return new WP_REST_Response( array_map( array( $this, 'prepare_policy' ), $this->policies->all() ), 200 ); } /** * Create a policy. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function create_item( $request ) { $rules = $this->validated_rules( $request ); if ( $rules instanceof \WP_Error ) { return $rules; } $policy = $this->policies->create( $this->requested_scope( $request ), $rules, (int) ( $request->get_param( 'priority' ) ?? 10 ) ); return new WP_REST_Response( $this->prepare_policy( $policy ), 201 ); } /** * Update a policy. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function update_item( $request ) { $id = (int) $request->get_param( 'id' ); if ( null === $this->policies->find( $id ) ) { return $this->not_found(); } $rules = $this->validated_rules( $request ); if ( $rules instanceof \WP_Error ) { return $rules; } $this->policies->update( $id, $rules, (int) ( $request->get_param( 'priority' ) ?? 10 ), $this->requested_scope( $request ) ); $updated = $this->policies->find( $id ); assert( $updated instanceof Policy ); return new WP_REST_Response( $this->prepare_policy( $updated ), 200 ); } /** * Delete a policy. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function delete_item( $request ) { $id = (int) $request->get_param( 'id' ); if ( null === $this->policies->find( $id ) ) { return $this->not_found(); } $this->policies->delete( $id ); return new WP_REST_Response( array( 'deleted' => true ), 200 ); } /** * Describe the rule grammar: the JSON Schema rules are validated against, * the values each dimension accepts, and what happens when no rule matches. * * The admin editor builds its controls from this, so a dimension added to * RuleSchema appears in the UI without a parallel edit here. * * @param WP_REST_Request $request Request. */ public function get_rule_schema( $request ): WP_REST_Response { $defaults = array_map( static function ( array $row ): array { // Probes exist to keep the description honest in tests; they // are not operator-facing. unset( $row['probes'] ); return $row; }, RuleSchema::defaults() ); return new WP_REST_Response( array( 'schema' => RuleSchema::json_schema(), 'defaults' => $defaults, 'dimensions' => array( 'action_cats' => RuleSchema::ACTION_CATS, 'sources' => RuleSchema::SOURCES, 'decisions' => RuleSchema::decisions(), 'core_entity_types' => RuleSchema::CORE_ENTITY_TYPES, 'high_risk_entities' => RuleSchema::HIGH_RISK_ENTITIES, 'condition_ops' => RuleSchema::CONDITION_OPS, ), // What conditions may name here, by fact (ADR-18 §4). 'facts' => $this->evaluator->fact_catalog(), ), 200 ); } /** * How often each stored rule has decided an action. * * Reports every rule that exists, including those with no history, since * a rule absent from the ledger is precisely the interesting case. What it * cannot say is why: a rule that never fires may be shadowed by an earlier * one, or may guard against something nobody has attempted. Both are * reported the same way, and the distinction is left to a human. * * @param WP_REST_Request $request Request. */ public function get_rule_usage( $request ): WP_REST_Response { $usage = $this->ledger->rule_usage(); $catalog = $this->evaluator->fact_catalog(); $rules = array(); $never = 0; foreach ( $this->policies->all() as $policy ) { foreach ( array_values( $policy->rules ) as $index => $rule ) { $hit = $usage[ $policy->id ][ $index ] ?? null; if ( null === $hit ) { ++$never; } $rules[] = array( 'policy_id' => $policy->id, 'agent_id' => $policy->agent_id, 'priority' => $policy->priority, 'rule_index' => $index, 'rule' => $rule, 'hits' => null === $hit ? 0 : $hit['hits'], 'last_fired' => null === $hit ? null : $hit['last_fired'], // Names a fact no active extension provides, so it fires // only if restrictive (ADR-18 §4). 'dormant' => is_array( $rule ) && Conditions::dormant( $rule, $catalog ), ); } } return new WP_REST_Response( array( 'rules' => $rules, 'total' => count( $rules ), 'never_fired' => $never, // How far back "never" actually reaches. With a retention // window in force, a rule that last fired before the ledger // begins is indistinguishable from one that never fired, and // the caller has to be able to say so. 'retention_days' => $this->retention->retention_days(), 'history_since' => $this->ledger->oldest_timestamp(), ), 200 ); } /** * Answer "what would happen if an agent did this", without doing it. * * Nothing here writes: no ledger row, no pending action, no rate budget * spent, and no anomaly alert raised. Token scopes are applied first, as * the live pipeline does, so the answer matches what a real request would * get rather than only what the policies say. * * @param WP_REST_Request $request Request. */ public function simulate( $request ): WP_REST_Response { $ability = (string) ( $request->get_param( 'ability' ) ?? '' ); $entity_id = (int) ( $request->get_param( 'entity_id' ) ?? 0 ); // Absent means undeclared, which is a third state rather than false. $destructive = $request->get_param( 'destructive' ); $action = new ActionDescriptor( (string) $request->get_param( 'source' ), (string) $request->get_param( 'action_cat' ), (string) $request->get_param( 'entity_type' ), $entity_id > 0 ? $entity_id : null, '' !== $ability ? $ability : null, array(), null, is_bool( $destructive ) ? $destructive : null ); $facts = $request->get_param( 'facts' ); if ( is_array( $facts ) && array() !== $facts ) { ksort( $facts, SORT_STRING ); $action = $action->with_facts( $facts ); } $agent_id = (int) ( $request->get_param( 'agent_id' ) ?? 0 ); $agent = $agent_id > 0 ? $this->agents->find_by_id( $agent_id ) : null; if ( null !== $agent && ! ScopeGate::permits( $agent, $action ) ) { return new WP_REST_Response( array( 'decision' => Decision::DENY->value, 'reason' => Evaluation::REASON_OUT_OF_SCOPE, 'matched' => false, 'policy_id' => null, 'rule_index' => null, 'explanation' => __( 'Denied before any policy is consulted: this action is outside the scopes on the agent\'s token.', 'agentic-daisy-ai-agent-firewall' ), ), 200 ); } $evaluation = $this->evaluator->explain( $agent_id, $action ); return new WP_REST_Response( array( 'decision' => $evaluation->decision->value, 'reason' => $evaluation->reason, 'matched' => $evaluation->matched_a_rule(), 'policy_id' => $evaluation->policy_id, 'rule_index' => $evaluation->rule_index, 'extension' => $evaluation->extension, 'unresolved' => $evaluation->unresolved, 'over_budget' => $evaluation->over_budget, 'explanation' => $this->explanation_for( $evaluation ), ), 200 ); } /** * Whether simulated facts are shaped like real ones: well-formed names, * scalar or list-of-scalar values, within the per-action cap. * * @param mixed $facts Request value. */ public static function valid_simulated_facts( $facts ): bool { if ( ! is_array( $facts ) || count( $facts ) > Refiner::MAX_FACTS ) { return false; } foreach ( $facts as $name => $value ) { if ( ! is_string( $name ) || 1 !== preg_match( Registry::FACT_PATTERN, $name ) ) { return false; } $items = is_array( $value ) ? $value : array( $value ); if ( ( is_array( $value ) && ! array_is_list( $value ) ) || count( $items ) > Refiner::MAX_LIST ) { return false; } foreach ( $items as $item ) { if ( ! is_scalar( $item ) ) { return false; } } } return true; } /** * Plain-language account of what decided, for the admin screen. * * @param Evaluation $evaluation Result of the dry-run. */ private function explanation_for( Evaluation $evaluation ): string { if ( Evaluation::REASON_RATE_LIMITED === $evaluation->reason ) { return sprintf( /* translators: 1: rule position, 2: policy id. */ __( 'Rule %1$d of policy %2$d sets a write limit the agent has already reached. Waiting clears it.', 'agentic-daisy-ai-agent-firewall' ), (int) $evaluation->rule_index + 1, (int) $evaluation->policy_id ); } if ( null !== $evaluation->extension ) { return sprintf( /* translators: 1: rule position, 2: extension id. */ __( 'Decided by default rule %1$d of the "%2$s" extension. No rule of yours matched; extension defaults apply before the built-in ones, and a rule of your own overrides them.', 'agentic-daisy-ai-agent-firewall' ), (int) $evaluation->rule_index + 1, $evaluation->extension ); } if ( ! $evaluation->matched_a_rule() ) { return __( 'No rule matched, so the built-in default above applied.', 'agentic-daisy-ai-agent-firewall' ); } if ( $evaluation->over_budget && ! $evaluation->unresolved ) { return sprintf( /* translators: 1: rule position, 2: policy id. */ __( 'Decided by rule %1$d of policy %2$d: with this action the agent would exceed the rule\'s budget for its window. Held actions count against it too, and earlier ones free budget as they age out of the window.', 'agentic-daisy-ai-agent-firewall' ), (int) $evaluation->rule_index + 1, (int) $evaluation->policy_id ); } if ( $evaluation->unresolved ) { return sprintf( /* translators: 1: rule position, 2: policy id. */ __( 'Decided by rule %1$d of policy %2$d: a fact it tests could not be evaluated - absent, or from an extension that is not active - and a rule that restricts fires when it cannot tell.', 'agentic-daisy-ai-agent-firewall' ), (int) $evaluation->rule_index + 1, (int) $evaluation->policy_id ); } return sprintf( /* translators: 1: rule position, 2: policy id. */ __( 'Decided by rule %1$d of policy %2$d, the first rule that matched.', 'agentic-daisy-ai-agent-firewall' ), (int) $evaluation->rule_index + 1, (int) $evaluation->policy_id ); } /** * Export every policy as a portable JSON document. * * Agent-scoped policies name their agent rather than carrying its row * id: ids are site-local, names are what an operator standardizes * across sites. Ids, timestamps and other site-local fields are * deliberately absent so the document round-trips cleanly. * * @param WP_REST_Request $request Request. */ public function export_policies( $request ): WP_REST_Response { $names = array(); foreach ( $this->agents->all() as $agent ) { $names[ $agent->id ] = $agent->name; } $policies = array(); foreach ( $this->policies->all() as $policy ) { $policies[] = array( 'agent' => null === $policy->agent_id ? null : ( $names[ $policy->agent_id ] ?? null ), 'priority' => $policy->priority, 'rules' => $policy->rules, ); } return new WP_REST_Response( array( 'format' => 'adaf-policies', 'version' => 1, 'exported_at' => wp_date( 'Y-m-d H:i:s' ), 'policies' => $policies, ), 200 ); } /** * Import a policy export document, append-only (PolicyImporter). * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function import_policies( $request ) { if ( 'adaf-policies' !== $request->get_param( 'format' ) || 1 !== (int) $request->get_param( 'version' ) ) { return new \WP_Error( 'adaf_invalid_import', __( 'This is not an Agent Firewall policy export (expected format "adaf-policies", version 1).', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 400 ) ); } $entries = $request->get_param( 'policies' ); $result = ( new PolicyImporter( $this->policies, $this->agents, $this->evaluator->fact_catalog() ) )->import( is_array( $entries ) ? $entries : array() ); return new WP_REST_Response( array( 'imported' => $result['imported'], 'skipped' => $result['skipped'], ), 200 ); } /** * The scope a request asks for: an agent id, or null for global. * * The arg is sanitized with absint, so an omitted or null agent_id arrives * as 0. Both spellings mean "applies to every agent". * * @param WP_REST_Request $request Request. */ private function requested_scope( $request ): ?int { $agent_id = $request->get_param( 'agent_id' ); return null === $agent_id || 0 === (int) $agent_id ? null : (int) $agent_id; } /** * Extract and validate the rules body param. * * @param WP_REST_Request $request Request. * @return array>|\WP_Error */ private function validated_rules( $request ) { $rules = $request->get_param( 'rules' ); if ( ! is_array( $rules ) ) { $rules = array(); } $errors = PolicyEvaluator::validate_rules( $rules, $this->evaluator->fact_catalog(), RetentionService::window_seconds() ); if ( array() !== $errors ) { return new \WP_Error( 'adaf_invalid_rules', __( 'The policy rules are invalid.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 400, 'errors' => $errors, ) ); } /** @var array> $rules */ return $rules; } /** * 404 helper. */ private function not_found(): \WP_Error { return new \WP_Error( 'adaf_not_found', __( 'Policy not found.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 404 ) ); } /** * Serialize a policy. * * @param Policy $policy Policy. * @return array */ private function prepare_policy( Policy $policy ): array { return array( 'id' => $policy->id, 'agent_id' => $policy->agent_id, 'rules' => $policy->rules, 'priority' => $policy->priority, 'created_at' => $policy->created_at->format( 'Y-m-d H:i:s' ), 'updated_at' => $policy->updated_at->format( 'Y-m-d H:i:s' ), ); } /** * Item schema. * * @return array */ public function get_item_schema(): array { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'adaf_policy', 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'agent_id' => array( 'type' => array( 'integer', 'null' ) ), 'rules' => RuleSchema::json_schema(), 'priority' => array( 'type' => 'integer' ), 'created_at' => array( 'type' => 'string' ), 'updated_at' => array( 'type' => 'string' ), ), ); } }