diff --git a/src/mcp_cucm_axl/prompts/__init__.py b/src/mcp_cucm_axl/prompts/__init__.py index c0d959e..3bff36a 100644 --- a/src/mcp_cucm_axl/prompts/__init__.py +++ b/src/mcp_cucm_axl/prompts/__init__.py @@ -20,15 +20,23 @@ shim is where the parameter contract lives. from . import ( audit_routing, cucm_sql_help, + hunt_pilot_audit, + inbound_did_audit, investigate_pattern, + phone_inventory_report, route_plan_overview, sip_trunk_report, + user_audit, ) __all__ = [ "audit_routing", "cucm_sql_help", + "hunt_pilot_audit", + "inbound_did_audit", "investigate_pattern", + "phone_inventory_report", "route_plan_overview", "sip_trunk_report", + "user_audit", ] diff --git a/src/mcp_cucm_axl/prompts/hunt_pilot_audit.py b/src/mcp_cucm_axl/prompts/hunt_pilot_audit.py new file mode 100644 index 0000000..7603f18 --- /dev/null +++ b/src/mcp_cucm_axl/prompts/hunt_pilot_audit.py @@ -0,0 +1,207 @@ +"""Hunt pilot + line group + queue settings audit.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ._common import render_schema_block + +if TYPE_CHECKING: + from ..docs_loader import DocsIndex + + +_KEYWORDS = [ + "hunt pilot", "hunt list", "line group", "distribution algorithm", + "queue", "rna", "ring no answer", "huntpilotqueue", +] + + +def render(docs: "DocsIndex | None") -> str: + """Hunt pilot inventory and audit. Schema-aware (uses + `huntpilotqueue.fknumplan_pilot`, NOT `fknumplan` — verified against + CUCM 15 schema 2026-04-25).""" + schema_block = render_schema_block( + docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900 + ) + + return """# CUCM Hunt Pilot Audit + +Hunt pilots distribute incoming calls across a group of phones (hunt +group) with configurable algorithms (top-down, longest-idle, broadcast, +etc.) and queue behavior. Misconfigured hunt pilots are a common source +of "calls disappear into the void" complaints. + +## Schema (CUCM 15) + +- `numplan` row with `tkpatternusage = 7` is a Hunt Pilot +- `huntpilotqueue` joins to numplan via `fknumplan_pilot` (NOT + `fknumplan` — that's a common-mistake column name) +- `linegroup` defines the distribution algorithm and member-handling +- `linegroupnumplanmap` joins line groups to their member DNs +- A hunt pilot points to a "Route List" device (just like route + patterns do) — that route list contains route groups containing + line groups containing phones + +## Step 1 — Hunt pilot inventory + +```sql +SELECT + np.dnorpattern AS hunt_pilot, + rp.name AS partition, + np.description, + np.calledpartytransformationmask AS xform_called, + np.callingpartytransformationmask AS xform_calling, + np.pkid +FROM numplan np +JOIN routepartition rp ON np.fkroutepartition = rp.pkid +WHERE np.tkpatternusage = 7 -- Hunt Pilot +ORDER BY rp.name, np.dnorpattern; +``` + +Each hunt pilot is a "front door" pattern — what callers dial to reach +a group. The transformations columns matter: a hunt pilot may rewrite +the called party (e.g., normalize to a base-extension) before +distribution. + +## Step 2 — Queue settings per hunt pilot + +```sql +SELECT + np.dnorpattern AS hunt_pilot, + hpq.maxcallersinqueue, + hpq.maxwaittimeinqueue, + hpq.maxwaittimedestination AS overflow_dest, + hpq.noagentdestination AS no_agent_dest, + hpq.queuefulldestination AS queue_full_dest, + css_max.name AS css_for_max_wait, + css_no.name AS css_for_no_agent, + css_full.name AS css_for_queue_full +FROM huntpilotqueue hpq +JOIN numplan np ON hpq.fknumplan_pilot = np.pkid +LEFT OUTER JOIN callingsearchspace css_max ON hpq.fkcallingsearchspace_maxwaittime = css_max.pkid +LEFT OUTER JOIN callingsearchspace css_no ON hpq.fkcallingsearchspace_noagent = css_no.pkid +LEFT OUTER JOIN callingsearchspace css_full ON hpq.fkcallingsearchspace_pilotqueuefull = css_full.pkid +ORDER BY np.dnorpattern; +``` + +Queue behavior is the most-misconfigured aspect of hunt pilots: +- `maxwaittimeinqueue = 0` means "no max" — callers can wait forever. + Usually a misconfiguration; should be set to a sensible value + (e.g., 30-300 seconds) with an overflow destination. +- `maxwaittimedestination` / `noagentdestination` / + `queuefulldestination` define what happens when each condition + triggers. NULL on any of these means "drop the call" — almost never + the operator's intent. + +## Step 3 — Line groups and their member DNs + +```sql +SELECT + lg.name AS line_group, + ta.name AS distribution_algorithm, + np.dnorpattern AS member_dn, + rp.name AS member_partition, + lgnpm.lineselectionorder AS sortorder +FROM linegroup lg +LEFT OUTER JOIN typedistributealgorithm ta ON lg.tkdistributealgorithm = ta.enum +LEFT OUTER JOIN linegroupnumplanmap lgnpm ON lgnpm.fklinegroup = lg.pkid +LEFT OUTER JOIN numplan np ON lgnpm.fknumplan = np.pkid +LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid +ORDER BY lg.name, lgnpm.lineselectionorder; +``` + +Distribution algorithms (decoded via `typedistributealgorithm`): +- **Top Down** — first member always rings first; predictable but + uneven load. +- **Circular** — round-robin starting after the last-rung member. +- **Longest Idle Time** — call goes to the member who hasn't rung in + the longest time. Most common for fairness. +- **Broadcast** — every member rings simultaneously. Use sparingly; + noisy. + +## Step 4 — Hunt pilot → route list / line group destinations + +The hunt pilot routes to a Route List that contains the actual line +group(s). Use the existing tool: + +``` +route_inspect_pattern(, ) +``` + +This returns the destination chain. For each hunt pilot identified in +Step 1, run this to confirm: +- Destination is the expected route list / line group +- The line group's distribution algorithm matches operational intent +- Member DNs all exist and belong to active phones (cross-reference + with `phone_inventory_report`) + +## Step 5 — Hunt pilots with no line group destination (dead pilots) + +```sql +-- Hunt pilots that don't appear in any line group routing +SELECT np.dnorpattern, rp.name AS partition, np.description +FROM numplan np +JOIN routepartition rp ON np.fkroutepartition = rp.pkid +WHERE np.tkpatternusage = 7 + AND NOT EXISTS ( + SELECT 1 FROM devicenumplanmap dnm + JOIN device d ON dnm.fkdevice = d.pkid + JOIN typeclass tc ON d.tkclass = tc.enum + WHERE dnm.fknumplan = np.pkid AND tc.name = 'Route List' + ) +ORDER BY rp.name, np.dnorpattern; +``` + +A hunt pilot that doesn't route to a route list is functionally dead — +calls match the pilot pattern but have nowhere to go. Often vestigial +config. + +## Findings to call out + +### Queue misconfigurations +- **`maxwaittimeinqueue = 0`** without an explicit overflow rationale + — callers can wait forever in queue. +- **NULL `maxwaittimedestination` / `noagentdestination` / + `queuefulldestination`** — calls drop without going anywhere. + Recommend explicit destinations (typically voicemail). +- **Mismatched CSSs** on the three queue destinations: the CSSs control + whether the destination is reachable. If the CSS is overly restrictive, + the overflow destination might not be reachable from the hunt pilot's + context. + +### Line group hygiene +- **Empty line groups** (no members in `linegroupnumplanmap`) — calls + would never ring anywhere. +- **Line groups with one member** — fine, but check whether a hunt + pilot is overkill (a direct DN may be simpler). +- **Members in an inactive partition** or pointing to a DN that's + no longer assigned to any phone. + +### Distribution algorithm clarity +- **Distribution = Broadcast** for a large group — operationally noisy; + confirm with operator whether this is intentional. +- **Distribution = Top Down** with the same first member every time — + load concentrates on one phone; operator may want longest-idle. + +### Coverage gaps +- **Hunt pilots not pointing to a route list** (Step 5) — cleanup. +- **Hunt pilot description missing** — operationally opaque; recommend + description annotations. + +## Suggested follow-up calls + +- `route_inspect_pattern(, )` for each hunt pilot to + trace its destination chain. +- `route_devices_using_css()` to + understand the blast radius of the CSSs the queue uses. +- `axl_describe_table('huntpilotqueue')` if the audit needs additional + queue-config columns (announcement, MoH source, etc.). + +## Reference: CUCM data dictionary (hunt pilots, line groups) + +""" + schema_block + """ + +Run the queries above and produce a structured findings report. Group +by hunt pilot; under each, list its queue config, line group(s), and +distribution algorithm; then list the audit findings with severity. +""" diff --git a/src/mcp_cucm_axl/prompts/inbound_did_audit.py b/src/mcp_cucm_axl/prompts/inbound_did_audit.py new file mode 100644 index 0000000..dc5f0c4 --- /dev/null +++ b/src/mcp_cucm_axl/prompts/inbound_did_audit.py @@ -0,0 +1,181 @@ +"""Inbound DID inventory: XFORM-Inbound-DNIS, screening, executed routing.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ._common import render_schema_block + +if TYPE_CHECKING: + from ..docs_loader import DocsIndex + + +_KEYWORDS = [ + "translation pattern", "called party transformation", "DNIS", + "inbound", "route pattern", "transformation mask", "PSTN", +] + + +def render(docs: "DocsIndex | None") -> str: + """Inventory of every PSTN DID that *might* be presented to the cluster, + cross-referenced against actual routing destinations. + + Pattern: turn the operator-curated XFORM-Inbound-DNIS list into a + "presentable DID inventory" with destination cross-checks. Surfaces + orphan target extensions, undocumented DIDs, and the silent-fallback + catch-all hazard. + """ + schema_block = render_schema_block( + docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900 + ) + + return """# CUCM Inbound DID Audit + +The cluster's inbound architecture has multiple transformation layers. +This audit reconstructs the full set of DIDs that "should" be presentable +and cross-checks them against actual routing destinations. + +## Conceptual model (verify on this cluster) + +Inbound calls usually traverse: + +1. PSTN-facing trunk (typically `PSTN-Router-SIP-Trk` or similar) → + trunk's primary CSS, e.g. `PSTN-Inbound-CSS`. +2. `PSTN-Inbound-PT` partition with a `!` translation that re-routes to + `PSTN-Screen-CSS` for spam filtering. +3. `PSTN-Screen-PT` with operator-curated `block_enabled=true` translation + patterns for spam blocking, plus a `!` / `\\+!` catch-all that + re-routes via `Internal-CSS`. +4. `Internal-CSS` finds the destination — either a 10-digit route pattern + in `Internal-PT` (typically routes to fax trunks) or a directory + number after some upstream transformation. + +The XFORM-Inbound-DNIS partition holds 100+ "Called Party Number +Transformation" patterns (`tkpatternusage = 20`) that map external DIDs +to internal extensions. **Whether they fire automatically depends on +the trunk's `fkcallingsearchspace_cntdpntransform` setting** — confirm +this with the operator or via: + +```sql +SELECT d.name, sd.fkcallingsearchspace_cntdpntransform +FROM device d JOIN sipdevice sd ON sd.fkdevice = d.pkid +JOIN typeclass tc ON d.tkclass = tc.enum +WHERE tc.name = 'Trunk'; +``` + +## Step 1 — Operator-curated DID inventory (XFORM-Inbound-DNIS) + +```sql +SELECT + np.dnorpattern AS inbound_did, + np.calledpartytransformationmask AS xform_to, + np.prefixdigitsout AS prefix_out, + np.description +FROM numplan np +JOIN routepartition rp ON np.fkroutepartition = rp.pkid +WHERE rp.name = 'XFORM-Inbound-DNIS' +ORDER BY np.dnorpattern; +``` + +Categorize the result: + +- **Pass-through 10-digit** (`xform_to IS NULL`, description "remain as 10-digit"): + the DID is expected to match a corresponding route pattern in `Internal-PT`. +- **Block-translation to 4-digit** (`xform_to LIKE '7XXX'` or similar): + rewrites last N digits to an internal extension range. +- **Specific renames** (literal `xform_to` like `7400`, `2523`): + one-off DID-to-extension mappings, often legacy clinic numbers. +- **Wildcard ranges** (pattern contains `[N-M]`, `XXX`, `.`): + efficient block mappings worth reviewing for bounds correctness. +- **Catch-all `!`**: dangerous silent fallback if present — flag it. + +## Step 2 — Actually-executed routing (Internal-PT route patterns ≥7 digits) + +```sql +SELECT np.dnorpattern, np.description, np.blockenable +FROM numplan np +JOIN routepartition rp ON np.fkroutepartition = rp.pkid +WHERE rp.name = 'Internal-PT' + AND np.tkpatternusage = 5 -- Route pattern + AND LENGTH(np.dnorpattern) >= 7 +ORDER BY np.dnorpattern; +``` + +Cross-check: +- Every "remain as 10-digit" DID from Step 1 should have a matching + 10-digit route pattern here. +- Every route pattern here should have a known purpose (typically routes + to a fax trunk or hunt list). + +## Step 3 — Spam blocklist (operator-curated, audit-relevant) + +```sql +SELECT np.dnorpattern, np.description +FROM numplan np +JOIN routepartition rp ON np.fkroutepartition = rp.pkid +WHERE rp.name = 'PSTN-Screen-PT' AND np.blockenable = 't' +ORDER BY np.dnorpattern; +``` + +Spam blocklists rotate every 30-60 days as campaigns shift. This is +informational, but persistently-stale entries (descriptions like +"Number spamming X's phone" from 1+ year ago) can be retired. + +## Step 4 — Verify target extensions exist + +For each unique target extension referenced in Step 1's renames, confirm +it actually exists as a real DN: + +```sql +-- Replace '7400, 2523, 2532, ...' with the unique targets from Step 1 +SELECT FIRST 50 np.dnorpattern, np.description, rp.name AS partition +FROM numplan np +JOIN routepartition rp ON np.fkroutepartition = rp.pkid +WHERE np.tkpatternusage = 2 -- Directory Number + AND np.dnorpattern IN ('7400', '2523', '2532', '...') +ORDER BY np.dnorpattern; +``` + +A DID that translates to an extension that doesn't exist is a *broken* +inbound DID — calls succeed in the transformation step but fail at the +final DN lookup. + +## Findings to call out + +- **Orphan target extensions**: any DID that translates to a non-existent + DN. High-severity audit finding — caller hears "number not found" or + reorder. +- **`!` catch-all in XFORM-Inbound-DNIS**: silently rewrites unrecognized + DIDs to their last 4 digits. Recommend documenting or removing — at + minimum, surface the risk. +- **Old clinic / legacy DIDs**: if multiple DIDs forward to the same + internal extension with descriptions referencing legacy locations, + confirm the target is a hunt pilot or reception line, not a single + user receiving misdirected calls. +- **Block-of-N pass-through declarations** (e.g., `20878594XX` declares + 100 DIDs): verify carrier allocation matches the block size. Excess + declarations are harmless but noisy. +- **Spam blocklist hygiene**: > 6-month-old entries that are no longer + active campaigns are cleanup candidates. +- **Stale block_enabled outbound DIDs** (`Internal-PT` route patterns + with `blockenable = 't'`): confirm intentional. Single oddballs among + many similar route patterns warrant a description annotation. + +## Suggested follow-up calls + +- `route_inspect_pattern()` for any specific DID where the audit + needs the full route trace (CSS reachability, destination chain). +- `axl_sql("SELECT * FROM numplan WHERE dnorpattern = ''")` + to verify each rewrite target exists. +- `route_devices_using_css('PSTN-Inbound-CSS')` to confirm the inbound + trunk(s) using this CSS — should typically be just 1. + +## Reference: CUCM data dictionary (translation patterns) + +""" + schema_block + """ + +Run the queries above and produce a structured findings report. Group +by DID handling category (Step 1 categorization), then list orphan- +target findings and other hygiene issues. Don't enumerate all 100+ +DIDs — group by exchange (208-XXX-) and call out exceptions. +""" diff --git a/src/mcp_cucm_axl/prompts/phone_inventory_report.py b/src/mcp_cucm_axl/prompts/phone_inventory_report.py new file mode 100644 index 0000000..d948ec3 --- /dev/null +++ b/src/mcp_cucm_axl/prompts/phone_inventory_report.py @@ -0,0 +1,197 @@ +"""Phone inventory + audit findings: device pools, CSSs, owners, anomalies.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ._common import render_schema_block + +if TYPE_CHECKING: + from ..docs_loader import DocsIndex + + +_KEYWORDS = [ + "phone", "device", "device pool", "calling search space", + "model", "owner", "extension mobility", +] + + +def _esc(s: str) -> str: + return s.replace("'", "''") + + +def render(docs: "DocsIndex | None", filter: str | None = None) -> str: + """Phone inventory: counts by class, distribution by pool/CSS/model, + plus findings around orphans, drift, and naming anomalies. + + Args: + filter: Optional substring (case-sensitive LIKE on `device.name` + OR `device.description`) to narrow the inventory. + """ + schema_block = render_schema_block( + docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900 + ) + + if filter: + safe = _esc(filter) + name_clause = f"\n AND (d.name LIKE '%{safe}%' OR d.description LIKE '%{safe}%')" + scope_note = f"narrowed to phones matching `%{filter}%`" + else: + name_clause = "" + scope_note = "all phones" + + return f"""# CUCM Phone Inventory — {scope_note} + +Produce a phone inventory with audit-relevant groupings and findings. +The cluster typically has hundreds-to-thousands of phones; this prompt +asks for *aggregations and anomalies*, not a flat dump. + +## Step 1 — Aggregate counts (always run first) + +```sql +SELECT tm.name AS model, COUNT(*) AS phone_count +FROM device d +JOIN typeclass tc ON d.tkclass = tc.enum +LEFT OUTER JOIN typemodel tm ON d.tkmodel = tm.enum +WHERE tc.name = 'Phone'{name_clause} +GROUP BY tm.name ORDER BY 2 DESC; +``` + +```sql +SELECT dp.name AS device_pool, COUNT(*) AS phone_count +FROM device d +JOIN typeclass tc ON d.tkclass = tc.enum +LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid +WHERE tc.name = 'Phone'{name_clause} +GROUP BY dp.name ORDER BY 2 DESC; +``` + +```sql +SELECT css.name AS css_name, COUNT(*) AS phone_count +FROM device d +JOIN typeclass tc ON d.tkclass = tc.enum +LEFT OUTER JOIN callingsearchspace css ON d.fkcallingsearchspace = css.pkid +WHERE tc.name = 'Phone'{name_clause} +GROUP BY css.name ORDER BY 2 DESC; +``` + +These three give the shape of the fleet. The CSS one in particular is +audit-critical: most phones should land in a small number of CSSs +(typically `National-CSS` for outbound dialing on this kind of cluster). +*Outliers* in the CSS distribution warrant individual inspection. + +## Step 2 — Anomaly queries (the audit-actionable findings) + +### Phones with no description or auto-generated descriptions +```sql +SELECT FIRST 50 d.name, d.description, dp.name AS device_pool +FROM device d +JOIN typeclass tc ON d.tkclass = tc.enum +LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid +WHERE tc.name = 'Phone'{name_clause} + AND (d.description IS NULL + OR d.description = '' + OR d.description = d.name + OR d.description LIKE 'AN%' || SUBSTRING(d.name FROM 3) || '%') +ORDER BY d.name; +``` + +(Phones whose description matches or echoes their MAC address / +auto-generated name. Hospital deployments accumulate these as patient- +room or mobile carts that were quickly added without operator notes.) + +### Phones with no associated owner +```sql +SELECT FIRST 50 d.name, d.description, dp.name AS device_pool +FROM device d +JOIN typeclass tc ON d.tkclass = tc.enum +LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid +WHERE tc.name = 'Phone'{name_clause} + AND d.fkenduser IS NULL +ORDER BY d.name; +``` + +(Some unassigned phones are intentional — common areas, guest phones, +patient rooms. Operator should verify that the count matches expected +shared-use endpoints.) + +### Phones in non-default CSS (where "default" means most-common) +After Step 1's CSS distribution query, identify the most common CSS, +then list phones NOT in it: + +```sql +SELECT FIRST 50 d.name, d.description, css.name AS css_name +FROM device d +JOIN typeclass tc ON d.tkclass = tc.enum +LEFT OUTER JOIN callingsearchspace css ON d.fkcallingsearchspace = css.pkid +WHERE tc.name = 'Phone'{name_clause} + AND (css.name != '' OR css.name IS NULL) +ORDER BY css.name, d.name; +``` + +(Replace `` with the result from Step 1.) + +### Phones with no primary CSS (NULL) +```sql +SELECT d.name, d.description, dp.name AS device_pool +FROM device d +JOIN typeclass tc ON d.tkclass = tc.enum +LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid +WHERE tc.name = 'Phone'{name_clause} AND d.fkcallingsearchspace IS NULL +ORDER BY d.name; +``` + +(NULL CSS means the phone inherits from device pool. Usually fine, but +worth confirming the count matches expectations — a sudden increase +indicates config drift.) + +## Step 3 — Cross-reference with users (if owners exist) + +For phones with an owner, list owner identities to spot stale +assignments (employee left, phone still tagged to them): + +```sql +SELECT FIRST 50 d.name, d.description, eu.userid, eu.lastname, eu.status +FROM device d +JOIN typeclass tc ON d.tkclass = tc.enum +JOIN enduser eu ON d.fkenduser = eu.pkid +WHERE tc.name = 'Phone'{name_clause} +ORDER BY d.name; +``` + +`enduser.status` of `0` = inactive; `1` = active. A phone owned by an +inactive user is a finding. + +## Findings to call out + +- **Naming hygiene gaps**: phones with auto-generated or empty descriptions. + Recommend: operational follow-up to add room/owner/purpose notes. +- **CSS sprawl**: if more than ~3-5 CSSs are in regular use, justify each. + Phones in single-purpose CSSs (e.g., `911CER-CSS` with only 9 devices) + usually have specific reasons — document them. +- **Orphan owners**: phones owned by inactive users. Recommend reassignment + or unassignment. +- **Model heterogeneity**: a wide spread of phone models indicates ongoing + refresh or drift. Worth knowing for firmware/upgrade planning. +- **Devicepool concentration**: if 99%+ of phones are in one device pool, + that's the pool that matters for any DP-related change. Flag it as a + high-blast-radius asset. + +## Suggested follow-up calls + +- `route_devices_using_css(css_name=)` to map the + full impact of changing any phone-attached CSS. +- `axl_describe_table('device')` if the LLM needs additional columns + (firmware load, MAC, security profile, etc.). +- `axl_sql("SELECT name FROM typemodel WHERE enum IN (...)")` to decode + any unfamiliar model codes. + +## Reference: CUCM data dictionary (devices) + +{schema_block} + +Run the queries above and produce a structured inventory report: +counts table, distribution tables, anomaly findings with severity, and +a recommendations section. Don't enumerate every phone — focus on +aggregates and exceptions. +""" diff --git a/src/mcp_cucm_axl/prompts/user_audit.py b/src/mcp_cucm_axl/prompts/user_audit.py new file mode 100644 index 0000000..05ec270 --- /dev/null +++ b/src/mcp_cucm_axl/prompts/user_audit.py @@ -0,0 +1,204 @@ +"""End user + application user audit: roles, group memberships, security.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ._common import render_schema_block + +if TYPE_CHECKING: + from ..docs_loader import DocsIndex + + +_KEYWORDS = [ + "end user", "application user", "directory group", "function role", + "role", "permission", "authentication", "ldap", +] + +# Focus → rationale + which audit checklist sections to emphasize +_FOCUSES = { + "full", + "admin", # users with admin/serviceability roles + "inactive", # users with status != active + "app_users", # service accounts +} + + +def render(docs: "DocsIndex | None", focus: str = "full") -> str: + """User audit: end users + application users, role assignments, + security-relevant findings. + + Args: + focus: One of "full", "admin", "inactive", "app_users". Tunes + which checklist sections the report emphasizes; all queries + still run for context. + """ + if focus not in _FOCUSES: + focus = "full" + schema_block = render_schema_block( + docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900 + ) + + return f"""# CUCM User Audit — focus: `{focus}` + +End users (`enduser`), application users (`applicationuser`), and their +role assignments via the dirgroup/functionrole join chain. Security and +operational hygiene findings. + +## Step 1 — Headcounts + +End users grouped by status: +```sql +SELECT status, COUNT(*) AS user_count FROM enduser GROUP BY status; +``` + +Application users (no status column — they're configuration objects, not +human accounts): +```sql +SELECT COUNT(*) AS app_user_count FROM applicationuser; +``` + +`enduser.status`: `1` = active, `0` = inactive. + +(Informix's `UNION ALL` rejects mixed-type columns even with `CAST`, +so two separate queries is the simplest portable form.) + +## Step 2 — End user inventory + +```sql +SELECT FIRST 100 + eu.userid, + eu.firstname, + eu.lastname, + eu.displayname, + eu.status, + eu.userrank, + eu.islocaluser +FROM enduser eu +ORDER BY eu.lastname, eu.firstname; +``` + +`islocaluser` = `t` means CCM-local, `f` means LDAP-synced. Mixing both is +common (admins local, hospital staff LDAP-synced). +`userrank` is a 1-5 integer that gates access to features by rank; +elevated ranks (4-5) usually correspond to admin populations. + +## Step 3 — Application users (service accounts) + +```sql +SELECT au.name, au.userrank, au.isstandard +FROM applicationuser au +ORDER BY au.userrank DESC, au.name; +``` + +`isstandard = 't'` is a built-in account (Cisco-shipped); `f` is operator- +created. Operator-created application users with high userrank are the +audit-critical population (these are typically API-access service accounts). + +## Step 4 — Role assignments + +End users / app users → dirgroups → function roles: + +```sql +-- End users with their group memberships +SELECT FIRST 200 + eu.userid, + eu.lastname, + dg.name AS dirgroup_name +FROM enduser eu +JOIN enduserdirgroupmap eudgm ON eudgm.fkenduser = eu.pkid +JOIN dirgroup dg ON eudgm.fkdirgroup = dg.pkid +WHERE dg.isstandard = 'f' OR dg.name LIKE '%Standard CCM%Admin%' OR dg.name LIKE '%Super%' +ORDER BY eu.userid, dg.name; +``` + +```sql +-- App users with their group memberships (always audit-relevant) +SELECT au.name AS app_user, dg.name AS dirgroup_name +FROM applicationuser au +JOIN applicationuserdirgroupmap audgm ON audgm.fkapplicationuser = au.pkid +JOIN dirgroup dg ON audgm.fkdirgroup = dg.pkid +ORDER BY au.name, dg.name; +``` + +```sql +-- Dirgroups → function roles (the actual permissions) +SELECT dg.name AS group_name, fr.name AS role_name, fr.description +FROM dirgroup dg +JOIN functionroledirgroupmap frdgm ON frdgm.fkdirgroup = dg.pkid +JOIN functionrole fr ON frdgm.fkfunctionrole = fr.pkid +WHERE dg.isstandard = 'f' OR dg.name LIKE '%Admin%' OR dg.name LIKE '%Super%' +ORDER BY dg.name, fr.name; +``` + +The audit question is: who has admin-grade role membership, and is each +assignment justified? + +## Step 5 — Phone owners (cross-reference) + +Phones tagged to inactive users: + +```sql +SELECT FIRST 50 + d.name AS phone_name, + d.description, + eu.userid AS owner_userid, + eu.lastname, + eu.status AS owner_status +FROM device d +JOIN typeclass tc ON d.tkclass = tc.enum +JOIN enduser eu ON d.fkenduser = eu.pkid +WHERE tc.name = 'Phone' AND eu.status = '0' +ORDER BY eu.lastname, d.name; +``` + +A phone owned by an inactive user (departed employee, expired account) +is config drift — either reassign or unassign. + +## Findings to call out + +### Security-critical +- **Application users with admin/superuser group membership**: each one + is an API key with elevated privileges. Document why each exists. +- **End users with `Standard CCM Super Users` or similar privileged + group memberships**: these are the people who can write-modify the + cluster. Confirm the population matches the documented admin team. +- **`islocaluser = 't'` accounts with admin privileges**: bypass LDAP/SSO, + may have static passwords. High-priority security review. +- **Application users with default Cisco-shipped passwords**: these don't + show in the schema (no plaintext access), but the existence of standard + app users (e.g., `CCMAdministrator`, `CCMSysUser`) with operator-set + passwords is worth a separate check via the GUI or a pen test. + +### Operational hygiene +- **Inactive users (status = 0) still in groups**: cleanup candidates. +- **Phones owned by inactive users**: reassign or unassign. +- **Users with no group membership**: likely just have basic phone + access; not a finding by itself but worth confirming if a sudden + population appears. + +### Drift indicators +- **End users created locally vs LDAP-synced ratio**: if local-user count + has grown over time, indicates either an LDAP sync failure or + out-of-band account creation. +- **Operator-created dirgroups (`isstandard = 'f'`)**: list and verify each + has a documented purpose. + +## Suggested follow-up calls + +- For each app user with API/admin access, run + `axl_sql("SELECT * FROM applicationuser WHERE name = ''")` to + inspect ACL flags (`acl*` columns). +- `axl_describe_table('enduser')` for additional fields (manager, + department, etc. if LDAP populates them). +- `axl_sql("SELECT name, description FROM dirgroup ORDER BY isstandard, name")` + to see all groups including standard Cisco-shipped ones. + +## Reference: CUCM data dictionary (users + roles) + +{schema_block} + +Run the queries above and produce a structured findings report. The +focus parameter (`{focus}`) means: emphasize the corresponding section +in the writeup, but include all sections for context. +""" diff --git a/src/mcp_cucm_axl/server.py b/src/mcp_cucm_axl/server.py index 0a400fe..159884b 100644 --- a/src/mcp_cucm_axl/server.py +++ b/src/mcp_cucm_axl/server.py @@ -385,6 +385,46 @@ def sip_trunk_report(name_filter: str | None = None) -> str: return _prompts.sip_trunk_report.render(_docs, name_filter) +@mcp.prompt +def phone_inventory_report(filter: str | None = None) -> str: + """Phone fleet audit: counts by model/pool/CSS, anomaly findings, + orphan-owner cross-check. + + Args: + filter: Optional substring (matches device name OR description) to + narrow the inventory. Omit to include all phones. + """ + return _prompts.phone_inventory_report.render(_docs, filter) + + +@mcp.prompt +def user_audit(focus: str = "full") -> str: + """End user + application user audit: roles, group memberships, + security-relevant findings. + + Args: + focus: One of "full", "admin", "inactive", "app_users". Tunes + which checklist sections the report emphasizes; all queries + still run for context. + """ + return _prompts.user_audit.render(_docs, focus) + + +@mcp.prompt +def inbound_did_audit() -> str: + """Inbound DID inventory: XFORM-Inbound-DNIS curated list, executed + routing in Internal-PT, spam blocklist, orphan-target cross-check.""" + return _prompts.inbound_did_audit.render(_docs) + + +@mcp.prompt +def hunt_pilot_audit() -> str: + """Hunt pilot audit: queue settings, distribution algorithms, line + group membership, dead-pilot detection. Schema-aware (uses + huntpilotqueue.fknumplan_pilot, the verified column name).""" + return _prompts.hunt_pilot_audit.render(_docs) + + # ==================================================================== # Bootstrap # ==================================================================== diff --git a/tests/test_prompts_package.py b/tests/test_prompts_package.py index 3563801..e5eafbe 100644 --- a/tests/test_prompts_package.py +++ b/tests/test_prompts_package.py @@ -164,4 +164,80 @@ def test_all_prompts_registered_in_server(): "audit_routing", "cucm_sql_help", "sip_trunk_report", + "phone_inventory_report", + "user_audit", + "inbound_did_audit", + "hunt_pilot_audit", }, f"unexpected prompt set: {names}" + + +# ---- new prompts: render-with-and-without-docs smoke tests ----------------- + +def test_phone_inventory_report_renders(fake_docs): + text = prompts.phone_inventory_report.render(fake_docs) + assert "Phone Inventory" in text + # Embedded SQL should query the `device` table with tkclass='Phone' + assert "tc.name = 'Phone'" in text + assert "all phones" in text + + +def test_phone_inventory_report_with_filter(fake_docs): + text = prompts.phone_inventory_report.render(fake_docs, "Lobby") + assert "%Lobby%" in text + assert "narrowed" in text + + +def test_phone_inventory_report_filter_escaped(fake_docs): + text = prompts.phone_inventory_report.render(fake_docs, "O'Hara") + assert "O''Hara" in text # SQL injection defense + + +def test_user_audit_default_focus(fake_docs): + text = prompts.user_audit.render(fake_docs) + assert "focus: `full`" in text + assert "applicationuser" in text + assert "enduser" in text + + +def test_user_audit_admin_focus(fake_docs): + text = prompts.user_audit.render(fake_docs, "admin") + assert "focus: `admin`" in text + + +def test_user_audit_unknown_focus_falls_back_to_full(fake_docs): + text = prompts.user_audit.render(fake_docs, "bogus_value") + assert "focus: `full`" in text + + +def test_inbound_did_audit_renders(fake_docs): + text = prompts.inbound_did_audit.render(fake_docs) + assert "Inbound DID Audit" in text + assert "XFORM-Inbound-DNIS" in text + assert "PSTN-Screen-PT" in text + + +def test_hunt_pilot_audit_uses_correct_column(fake_docs): + """Hamilton bonus finding: huntpilotqueue joins via fknumplan_pilot, + NOT fknumplan. The prompt's embedded SQL must use the correct column + or every audit run will silently fail one category.""" + text = prompts.hunt_pilot_audit.render(fake_docs) + assert "fknumplan_pilot" in text, ( + "hunt_pilot_audit must use the verified column name `fknumplan_pilot`; " + "`fknumplan` was the silent-failure version we fixed in Hamilton review" + ) + assert "tkpatternusage = 7" in text # Hunt Pilot type code + + +def test_all_new_prompts_render_without_docs(): + """Graceful degradation: each prompt produces usable output even when + the docs index isn't loaded.""" + for name, fn, args in [ + ("phone_inventory_report", prompts.phone_inventory_report.render, ()), + ("user_audit", prompts.user_audit.render, ()), + ("inbound_did_audit", prompts.inbound_did_audit.render, ()), + ("hunt_pilot_audit", prompts.hunt_pilot_audit.render, ()), + ]: + text = fn(None, *args) + assert "cisco-docs index is not loaded" in text, ( + f"{name} failed graceful degradation" + )