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', ), 'agent_id' => array( 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), ), ), ) ); 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, ), ), 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(); $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'], ); } } 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 ); $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() ); $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, 'explanation' => $this->explanation_for( $evaluation ), ), 200 ); } /** * 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 ( ! $evaluation->matched_a_rule() ) { return __( 'No rule matched, so the built-in default above applied.', 'agentic-daisy-ai-agent-firewall' ); } 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 by design: importing never deletes or edits what is * already here. Entries are skipped - and reported, entry by entry - * rather than repaired when they cannot import faithfully: a scoped * policy with no same-named agent here, an entry whose rules fail * validation, or an exact duplicate of an existing policy. Silently * broadening a scoped policy to global would change what it protects. * * @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 ) ); } // First name wins on a rare duplicate name; the report names the // agent, so an operator can see exactly where a policy attached. $agents_by_name = array(); foreach ( $this->agents->all() as $agent ) { if ( ! isset( $agents_by_name[ $agent->name ] ) ) { $agents_by_name[ $agent->name ] = $agent->id; } } // Fingerprints of existing policies, so duplicates inside the // document and against the site are both caught. $seen = array(); foreach ( $this->policies->all() as $existing ) { $seen[ $this->policy_fingerprint( $existing->agent_id, $existing->priority, $existing->rules ) ] = true; } $entries = $request->get_param( 'policies' ); $imported = 0; $skipped = array(); foreach ( array_values( is_array( $entries ) ? $entries : array() ) as $index => $entry ) { $verdict = $this->import_entry( is_array( $entry ) ? $entry : array(), $agents_by_name, $seen ); if ( null === $verdict ) { ++$imported; continue; } $skipped[] = array( 'index' => $index, 'reason' => $verdict, ); } return new WP_REST_Response( array( 'imported' => $imported, 'skipped' => $skipped, ), 200 ); } /** * Import one entry; returns null on success or the skip reason. * * @param array $entry Document entry. * @param array $agents_by_name Agent name → id. * @param array $seen Fingerprints of stored policies (grows as entries import). */ private function import_entry( array $entry, array $agents_by_name, array &$seen ): ?string { $agent_name = $entry['agent'] ?? null; $agent_id = null; if ( null !== $agent_name && '' !== $agent_name ) { if ( ! is_string( $agent_name ) || ! isset( $agents_by_name[ $agent_name ] ) ) { return sprintf( /* translators: %s: agent name from the imported document. */ __( 'No agent named "%s" exists on this site; create it first or import the policy as global yourself.', 'agentic-daisy-ai-agent-firewall' ), is_string( $agent_name ) ? $agent_name : '' ); } $agent_id = $agents_by_name[ $agent_name ]; } $rules = isset( $entry['rules'] ) && is_array( $entry['rules'] ) ? $entry['rules'] : array(); $errors = PolicyEvaluator::validate_rules( $rules ); if ( array() !== $errors ) { return implode( ' ', $errors ); } $priority = isset( $entry['priority'] ) && is_numeric( $entry['priority'] ) ? (int) $entry['priority'] : 10; $fingerprint = $this->policy_fingerprint( $agent_id, $priority, $rules ); if ( isset( $seen[ $fingerprint ] ) ) { return __( 'An identical policy (same scope, priority and rules) already exists.', 'agentic-daisy-ai-agent-firewall' ); } /** @var array> $rules */ $this->policies->create( $agent_id, $rules, $priority ); $seen[ $fingerprint ] = true; return null; } /** * Identity of a policy for duplicate detection. * * @param int|null $agent_id Scope. * @param int $priority Priority. * @param array> $rules Rules list. */ private function policy_fingerprint( ?int $agent_id, int $priority, array $rules ): string { return ( $agent_id ?? 'global' ) . '|' . $priority . '|' . (string) wp_json_encode( $rules ); } /** * 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 ); 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' ), ), ); } }