# What this plugin offers, and what it asks for Written from the shipped source, and checked against it in both directions on every release: a hook in the code and not in this file fails the build, and a hook in this file and not in the code fails it too. Three roles, because they are not the same thing and `add_filter` is how you **answer** a filter, not how you consume one: - **Offers** — `do_action` / `apply_filters` on a `nlsec_` name. Yours to hook. - **Answers** — `add_filter` on a name a consumer applies. We supply the value. - **Asks** — `apply_filters` on a `wpmgr_` name. Somebody else supplies it. --- ## Offers — ten hooks on our own prefix | Hook | Kind | Fires when | Notes for a consumer | |---|---|---|---| | `nlsec_request_blocked` | action | Just before a request is refused | The only place a refusal is observable. It fires *only* when somebody is being refused, so anything here runs on the path a real visitor is stuck on. | | `nlsec_finding_recorded` | action | A scanner or vulnerability finding is written | Carries a machine-readable `code`. Match on that, never on the title — a rule matching a title dies the day anybody translates one. | | `nlsec_scan_complete` | action | A scan finishes | Fires on a clean scan too. "No findings" and "never ran" are different states. | | `nlsec_privileged_login` | action | An administrator signs in | | | `nlsec_recovery_code_used` | action | A two-factor recovery code is spent | Worth alerting on. A spent recovery code means somebody lost their second factor, or somebody else found it. | | `nlsec_ai_briefing` | action | The analyst produces a briefing | | | `nlsec_ai_acted` | action | The analyst acts on its own proposal | | | `nlsec_firewall_rules` | filter | Rules are assembled | Add or remove rules. | | `nlsec_malware_signatures` | filter | Signatures are assembled | A pattern that does not compile is reported by `NLSEC_Signatures::broken()`, not turned into a warning on every file. | | `nlsec_public_rest_routes` | filter | REST lockdown decides what stays public | **Read this one if you ship REST routes.** When `hardening` locks the REST API down, only the prefixes in this list stay reachable to a logged-out visitor. Defaults include `/wc/store`, `/contact-form-7`, `/wpcf7`. A plugin whose routes a shop's front end needs should add its prefix here rather than ask the owner to switch the lockdown off. Off by default, because most modern themes use REST from the front end. | | `nlsec_request_reputation` | filter | — | Defined here, answered here, **applied by the consumer**. See below. | ## Answers — four | Hook | Who applies it | What we return | |---|---|---| | `nlsec_request_reputation` | Any plugin that wants a verdict on a submitter | An array, or the caller's `null` untouched if we are absent. | | `wpmgr_dashboard_cards` | `shared-menu.php` | Our dashboard card. | | `wpmgr_signals` | **Nolimit Assistant, and nothing else** | Our signals. | | `wpmgr_setup_recommendations` | **Nolimit Assistant, and nothing else** | Three hardening switches for its Setup screen: hide the WordPress version and turn off pingbacks (both `safe`, both with an undo), and browser security headers (`confirm`). Each `done` is read from the stored setting, each `apply` returns `true` or a `WP_Error`, and none offers a number or a secret. We answer it and never apply it. | **`wpmgr_signals` is collected by Nolimit Assistant alone.** With that plugin deactivated our signals go to a filter nobody applies, and **nothing raises an error** — the answering side of a filter cannot tell whether anyone ever collected it. A collector has to live somewhere; anyone reading a family-wide signal list is reading Assistant's. ### `nlsec_request_reputation` in full $verdict = apply_filters( 'nlsec_request_reputation', null, $ip ); `$ip` is optional and defaults to the current request. `null` back means **nobody answered** — read it as "nothing is known", never as "clean". The consumer keeps its own fallback and carries no hard dependency on us. Returns: | Field | | |---|---| | `suspicious` | bool | | `exempt` | bool — the address is on the site owner's allowlist | | `reason` | a sentence for a human | | `code` | `blocked-now`, `on-threat-list`, `blocked-recently`, `near-rate-limit`, `allowlisted`, or `''` | | `severity` | 0–5 | | `measured` | which of `allowlist`, `blocks`, `lists`, `log`, `rate` actually answered | | `ip` | the address judged | **Severity is pinned** and asserted by the plugin's own tests, because a consumer branches on the number: `blocked-now` 5, `on-threat-list` 4, `blocked-recently` 2–4, `near-rate-limit` 2, `allowlisted` 0. Moving one is a red test that names who to tell. **`measured` is the field to wire in.** "Nothing known about this address" and "I could not look" are both zeros; `measured` is how a caller tells them apart. If `log` is missing our log table was unreadable, and a clean verdict from four sources is not the same claim as one from five. **`exempt` is stated, not implied.** `suspicious => false` is what a clean stranger gets too. An allowlisted address must never be refused through us, because we do not refuse it ourselves: `run_main()` checks the allowlist before the block list, so an allowlisted address carrying a stale block row is waved past by our own firewall. **What a consumer must not do:** - Do not treat `null` as clean. - Do not act on `severity` without also handling `exempt`. - Do not check the current user's capability through us — this filter takes an arbitrary address, which need not be the requester's, so "is this a logged-in administrator" is a question only the caller can answer. - **Do not both answer and apply this filter.** A provider that also reads the filter it answers builds an infinite mutual recursion, invisible in either file because each half is three obvious lines. The only `apply_filters( 'nlsec_request_reputation' )` in our shipped tree is inside the docblock above it. `wpmgr_dashboard_cards` is answered *and* applied in this tree, and that is safe only because the two are in different files doing different jobs: `class-plugin.php` adds our card, `shared-menu.php` collects everybody's. Our callback does not re-apply. That is the line; it is one edit away from being crossed. ## Emits to the family — one | Hook | Kind | Who listens | What it carries | |---|---|---|---| | `wpmgr_ai_usage` | action | Nolimit Assistant | `( 'nolimit-security', array( 'provider', 'model', 'input', 'output', 'purpose' ) )` — what this plugin spent on an AI call. | The point of it is that one plugin can show **one honest total** rather than four partial ones. It is a `wpmgr_` name we emit rather than ask for. ## Asks — four | Hook | Who answers | What we do when nobody does | |---|---|---| | `wpmgr_ai_credentials` | Nolimit Assistant | The analyst stays off. No error, no nag. | | `wpmgr_dashboard_cards` | Every family plugin | Draw the cards we got. | | `wpmgr_menu_title` | Any family plugin | Fall back to the built-in title. | | `wpmgr_menu_glyph` | Any family plugin | Fall back to the built-in glyph. | ## The card contract The shared menu reads exactly `title`, `colour`, `status`, `headline`, `lines`, `url` and `action` from a card. Four mistakes are easy to make: - `href` instead of `url` — the card draws, the button goes nowhere - no `colour` — falls back to a neutral grey - `lines` as a numeric list instead of `label => value` — values with no labels - `name` instead of `title` — a card without a `title` is skipped, so the **whole card disappears**, silently. The only one of the four that renders as absence. ## WordPress core hooks we use as an interface `plugins_api` — guarded on `$args->slug`. `rest_api_init` — registers `nlsec/v1`. Listed so a core hook can be told from an undocumented one of ours. The update filter `update_plugins_{$host}` is shared by every plugin that updates from the same host, so ours answers only for itself. ## Internal only — not a contract `nlsec_hourly_event`, `nlsec_scan_event`, `nlsec_ai_briefing_event`, `nlsec_ai_triage_event`, `nlsec_digest_event`, `nlsec_surge_check` are our own scheduled hooks. Nothing outside this plugin should hook them and they may be renamed without notice.