Summary
Add a read-only, capability-gated GET /kntnt-extractor/v1/environment endpoint returning the site's runtime and configuration facts — PHP version, database server flavour/version/collation, WordPress URLs/paths/prefix/core version, active plugins, present drop-ins, and the site's resolved non-secret wp-config defines.
This exists for the kntnt-wp-skills#24 hard cutover: kntnt-wp-skills's clone/pull skills are moving off the Novamira MCP (arbitrary execute-php) onto this plugin's REST surface. Their discovery phase used to gather a batch of site facts by running arbitrary PHP. Most of those facts are reconstructable from the existing tables/files/extractions surface — but a handful are not obtainable at all through it, and one class (defines) is only obtainable in a way that regresses the caller's security posture. This endpoint closes that gap while keeping the plugin generic (it exposes "facts about this install", never any kntnt-wp-skills-specific categorisation — ADR-0003 stays intact).
Why these facts have no other source
| Fact the caller needs |
Available today? |
| DB total size, per-table sizes, table prefix (derivable from names) |
✅ GET /tables |
| Uploads breakdown, drop-ins present, installed themes/plugins (dirs) |
✅ GET /files manifest |
| Live PHP version |
❌ runtime only — in no file and no table dump |
DB server flavour + version (MySQL 8.x vs MariaDB 11.x, and the exact major.minor) |
❌ Table_Dumper emits per-table DROP/CREATE/INSERT only — no -- Server version header, so a dump cannot reveal it |
| DB default collation |
⚠️ inferable from CREATE TABLE DDL, but only after extracting a table; cheap to serve here |
home/site URL, core version, active-plugins option |
⚠️ only by extracting & parsing wp_options / version.php |
| wp-config defines |
⚠️ only by extracting wp-config.php whole — which drags DB_PASSWORD, salts and nonces onto the caller's disk in cleartext |
The last row is the important one. kntnt-wp-skills has a hard rule that the database password never leaves production (its safety rail 8 / user story 40). If the skills had to recover defines by extracting wp-config.php, that rule breaks. Resolving defines here, server-side, with the secret family redacted to null, preserves that rule — the plugin is the only place that can read the config without shipping the secrets. So this is a security-preserving move, not scope creep.
The PHP and DB-server versions pin the caller's local DDEV environment. Without them the caller cannot avoid the "MySQL-8 dump crashes a MariaDB import on modern collations" failure or match production's PHP — both documented, load-bearing correctness properties on the caller's side. They are unrecoverable by any amount of extraction.
Decision E1 — how broad should the endpoint be?
Minimal (just the two forced facts) vs fuller (all the cheap generic facts, so the caller needs no wp_options bootstrap parse and never pulls wp-config.php).
Recommended: the fuller contract below. Rationale per field group:
php_version, database.{server,version,collation} — forced; no other source (see table).
defines (resolved, secret family redacted) — security-preserving; keeps the DB password/salts on production (see above). Redaction denylist below.
wordpress.{home_url,site_url,core_version,table_prefix,content_dir,uploads_dir} — cheap get_bloginfo/get_option/wp_upload_dir one-liners; also lets the caller verify it is talking to production and not its local copy (home_url) before any heavy work, and map non-default content/uploads layouts. Paths are relative to the install root (no absolute server paths — least disclosure).
active_plugins, dropins — generic site state every migration/staging tool wants (which plugins are active; which drop-ins are present). dropins is also derivable from the manifest, so it MAY be dropped if you prefer the leaner surface.
server_software — best-effort from $_SERVER['SERVER_SOFTWARE']; informational only.
If you prefer a leaner endpoint (e.g. versions + defines only, everything else from extraction), say so and I'll trim the contract before this goes ready-for-agent.
Secret-define redaction denylist (fixed rule, not caller-specific)
Resolve each define's live value via defined()/constant(), except this family, whose value is emitted as null and never read:
- exact:
DB_PASSWORD, AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY
- suffix
*_SALT (covers AUTH_SALT, SECURE_AUTH_SALT, LOGGED_IN_SALT, NONCE_SALT)
- prefix
NONCE_* (covers NONCE_KEY, NONCE_SALT)
(This mirrors kntnt-wp-skills's is_secret_define(), giving defence in depth at both ends of the boundary.)
Proposed contract
GET /kntnt-extractor/v1/environment (permission: shared Authorizer — both caps, 403 otherwise)
200 →
{
"php_version": "8.2.18",
"server_software": "Apache/2.4.57",
"wordpress": {
"core_version": "6.5.2",
"home_url": "https://example.com",
"site_url": "https://example.com/wp",
"table_prefix": "wp_",
"content_dir": "wp-content", // relative to the installation root
"uploads_dir": "wp-content/uploads" // relative to the installation root
},
"database": {
"server": "mariadb", // "mysql" | "mariadb", derived from @@version_comment/VERSION()
"version": "11.4.2",
"collation": "utf8mb4_unicode_520_ci" // @@collation_database
},
"active_plugins": ["akismet/akismet.php", "..."],
"dropins": ["object-cache.php", "advanced-cache.php"],
"defines": [
{ "name": "WP_MEMORY_LIMIT", "value": "256M" },
{ "name": "DB_PASSWORD", "value": null }, // redacted family → null
{ "name": "WP_DEBUG", "value": false }
]
}
- Gated by the existing shared
Authorizer (kntnt_extractor_operate + manage_options), like /tables — read-only, no parameters, no side effects.
server is derived from @@version_comment/VERSION() (e.g. contains "MariaDB" → mariadb, else mysql).
defines names come from a light regex over the located wp-config.php source (the same "find define('NAME'" approach the caller uses today), values resolved live via constant() with the denylist redacted — never the raw unevaluated source expression.
Acceptance criteria
- AC1 — an authorized
GET /environment returns php_version, database.{server,version,collation}, wordpress.{core_version,home_url,site_url,table_prefix,content_dir,uploads_dir}, active_plugins, dropins, and defines.
- AC2 — an anonymous or single-capability caller is refused 403 (same behaviour as
/tables).
- AC3 — every define in the redaction family (
DB_PASSWORD, the four exact keys, *_SALT, NONCE_*) is present in defines by name with value: null; no secret value is ever emitted.
- AC4 —
wordpress.content_dir / uploads_dir are relative to the installation root and correct for a non-default WP_CONTENT_DIR/uploads layout; no absolute server path is disclosed.
- AC5 —
database.server is mariadb on a MariaDB backend and mysql on a MySQL backend.
- AC6 — increments the REST API version to 2 (a new caller-visible endpoint);
GET /status reports 2. (If the sibling cutover tickets land in the same release, this is a single bump to 2 — coordinate, don't bump per ticket.)
Testing (test-first, per the project's red → green rhythm)
- Playground integration (
tests/Integration/*-test.php): shape + both-caps gate (403) + secret redaction (seed a DB_PASSWORD/salt define, assert value: null) + active_plugins/dropins reflect seeded state + relative content/uploads paths. Follows the existing files-endpoint-test.php/tables-test.php pattern.
- DDEV/MySQL harness (
tests/Integration/DDEV/): the real php_version and database.{server,version,collation} values can only be asserted against a real MySQL-family engine — Playground/SQLite cannot report them (exactly as tables-size-test.php already notes for SHOW TABLE STATUS). Add an assertion there that database.server/version are non-empty and plausible and php_version matches the container's PHP.
References
- Origin:
kntnt-wp-skills#24 cutover (this plugin is that project's companion control channel). The caller's old discovery payload is kntnt-wp-skills/templates/discovery.php — this endpoint replaces the runtime/config half of it.
- ADR-0002 (authn/authz — reuse the
Authorizer), ADR-0003 (generic surface, no server-side categorisation — this endpoint exposes only generic facts, honouring it), ADR-0005 (API version).
- Code touch points: new
classes/Rest/Environment_Controller.php (mirror Tables_Controller), wired in Plugin.php on rest_api_init; Status_Controller::API_VERSION bump.
DECIDED (maintainer, 2026-07-22) — now ready-for-agent. E1 → implement the fuller contract above (versions + urls/prefix/core + active_plugins + dropins + resolved, secret-redacted defines). Gated by the both-capabilities Authorizer, so only a holder of an application password for a user with both caps can read it — and the redaction denylist applies even to that authorized caller, so no secret value is ever returned. Sibling cutover tickets: #16 (structure-only extraction), #17 (list caller's own jobs).
Summary
Add a read-only, capability-gated
GET /kntnt-extractor/v1/environmentendpoint returning the site's runtime and configuration facts — PHP version, database server flavour/version/collation, WordPress URLs/paths/prefix/core version, active plugins, present drop-ins, and the site's resolved non-secretwp-configdefines.This exists for the kntnt-wp-skills#24 hard cutover:
kntnt-wp-skills'sclone/pullskills are moving off the Novamira MCP (arbitraryexecute-php) onto this plugin's REST surface. Their discovery phase used to gather a batch of site facts by running arbitrary PHP. Most of those facts are reconstructable from the existingtables/files/extractionssurface — but a handful are not obtainable at all through it, and one class (defines) is only obtainable in a way that regresses the caller's security posture. This endpoint closes that gap while keeping the plugin generic (it exposes "facts about this install", never anykntnt-wp-skills-specific categorisation — ADR-0003 stays intact).Why these facts have no other source
GET /tablesGET /filesmanifestmajor.minor)Table_Dumperemits per-tableDROP/CREATE/INSERTonly — no-- Server versionheader, so a dump cannot reveal itCREATE TABLEDDL, but only after extracting a table; cheap to serve herehome/siteURL, core version, active-plugins optionwp_options/version.phpwp-config.phpwhole — which dragsDB_PASSWORD, salts and nonces onto the caller's disk in cleartextThe last row is the important one.
kntnt-wp-skillshas a hard rule that the database password never leaves production (its safety rail 8 / user story 40). If the skills had to recover defines by extractingwp-config.php, that rule breaks. Resolving defines here, server-side, with the secret family redacted tonull, preserves that rule — the plugin is the only place that can read the config without shipping the secrets. So this is a security-preserving move, not scope creep.The PHP and DB-server versions pin the caller's local DDEV environment. Without them the caller cannot avoid the "MySQL-8 dump crashes a MariaDB import on modern collations" failure or match production's PHP — both documented, load-bearing correctness properties on the caller's side. They are unrecoverable by any amount of extraction.
Decision E1 — how broad should the endpoint be?
Minimal (just the two forced facts) vs fuller (all the cheap generic facts, so the caller needs no
wp_optionsbootstrap parse and never pullswp-config.php).Recommended: the fuller contract below. Rationale per field group:
php_version,database.{server,version,collation}— forced; no other source (see table).defines(resolved, secret family redacted) — security-preserving; keeps the DB password/salts on production (see above). Redaction denylist below.wordpress.{home_url,site_url,core_version,table_prefix,content_dir,uploads_dir}— cheapget_bloginfo/get_option/wp_upload_dirone-liners; also lets the caller verify it is talking to production and not its local copy (home_url) before any heavy work, and map non-default content/uploads layouts. Paths are relative to the install root (no absolute server paths — least disclosure).active_plugins,dropins— generic site state every migration/staging tool wants (which plugins are active; which drop-ins are present).dropinsis also derivable from the manifest, so it MAY be dropped if you prefer the leaner surface.server_software— best-effort from$_SERVER['SERVER_SOFTWARE']; informational only.If you prefer a leaner endpoint (e.g. versions + defines only, everything else from extraction), say so and I'll trim the contract before this goes
ready-for-agent.Secret-define redaction denylist (fixed rule, not caller-specific)
Resolve each define's live value via
defined()/constant(), except this family, whose value is emitted asnulland never read:DB_PASSWORD,AUTH_KEY,SECURE_AUTH_KEY,LOGGED_IN_KEY*_SALT(coversAUTH_SALT,SECURE_AUTH_SALT,LOGGED_IN_SALT,NONCE_SALT)NONCE_*(coversNONCE_KEY,NONCE_SALT)(This mirrors
kntnt-wp-skills'sis_secret_define(), giving defence in depth at both ends of the boundary.)Proposed contract
Authorizer(kntnt_extractor_operate+manage_options), like/tables— read-only, no parameters, no side effects.serveris derived from@@version_comment/VERSION()(e.g. contains "MariaDB" →mariadb, elsemysql).definesnames come from a light regex over the locatedwp-config.phpsource (the same "finddefine('NAME'" approach the caller uses today), values resolved live viaconstant()with the denylist redacted — never the raw unevaluated source expression.Acceptance criteria
GET /environmentreturnsphp_version,database.{server,version,collation},wordpress.{core_version,home_url,site_url,table_prefix,content_dir,uploads_dir},active_plugins,dropins, anddefines./tables).DB_PASSWORD, the four exact keys,*_SALT,NONCE_*) is present indefinesby name withvalue: null; no secret value is ever emitted.wordpress.content_dir/uploads_dirare relative to the installation root and correct for a non-defaultWP_CONTENT_DIR/uploads layout; no absolute server path is disclosed.database.serverismariadbon a MariaDB backend andmysqlon a MySQL backend.GET /statusreports2. (If the sibling cutover tickets land in the same release, this is a single bump to 2 — coordinate, don't bump per ticket.)Testing (test-first, per the project's red → green rhythm)
tests/Integration/*-test.php): shape + both-caps gate (403) + secret redaction (seed aDB_PASSWORD/salt define, assertvalue: null) +active_plugins/dropinsreflect seeded state + relative content/uploads paths. Follows the existingfiles-endpoint-test.php/tables-test.phppattern.tests/Integration/DDEV/): the realphp_versionanddatabase.{server,version,collation}values can only be asserted against a real MySQL-family engine — Playground/SQLite cannot report them (exactly astables-size-test.phpalready notes forSHOW TABLE STATUS). Add an assertion there thatdatabase.server/versionare non-empty and plausible andphp_versionmatches the container's PHP.References
kntnt-wp-skills#24cutover (this plugin is that project's companion control channel). The caller's old discovery payload iskntnt-wp-skills/templates/discovery.php— this endpoint replaces the runtime/config half of it.Authorizer), ADR-0003 (generic surface, no server-side categorisation — this endpoint exposes only generic facts, honouring it), ADR-0005 (API version).classes/Rest/Environment_Controller.php(mirrorTables_Controller), wired inPlugin.phponrest_api_init;Status_Controller::API_VERSIONbump.