@knowttl/mg-axi 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +149 -38
  2. package/dist/api.js +134 -8
  3. package/dist/bin/mg-axi-hook.js +31 -0
  4. package/dist/catalogue.js +390 -2
  5. package/dist/cli.js +116 -8
  6. package/dist/docs.js +45 -5
  7. package/dist/doctor.js +2 -2
  8. package/dist/entra-access-reviews.js +11 -4
  9. package/dist/entra-apps.js +13 -8
  10. package/dist/entra-audit-logs.js +8 -4
  11. package/dist/entra-auth-methods.js +8 -4
  12. package/dist/entra-certificate-auth.js +12 -8
  13. package/dist/entra-conditional-access.js +9 -4
  14. package/dist/entra-contacts.js +19 -12
  15. package/dist/entra-contracts.js +7 -4
  16. package/dist/entra-custom-security-attributes.js +19 -12
  17. package/dist/entra-data-policy-operations.js +7 -4
  18. package/dist/entra-delegated-admin.js +55 -6
  19. package/dist/entra-deleted-items.js +6 -4
  20. package/dist/entra-directory-objects.js +6 -4
  21. package/dist/entra-directory.js +19 -12
  22. package/dist/entra-domains.js +13 -8
  23. package/dist/entra-entitlement-management.js +12 -4
  24. package/dist/entra-group-pim.js +431 -0
  25. package/dist/entra-groups.js +13 -8
  26. package/dist/entra-lifecycle-workflows.js +136 -9
  27. package/dist/entra-organization.js +13 -8
  28. package/dist/entra-risk-prevention.js +436 -0
  29. package/dist/entra-risk.js +213 -5
  30. package/dist/entra-roles.js +10 -4
  31. package/dist/entra-subscriptions.js +6 -4
  32. package/dist/entra-users.js +7 -4
  33. package/dist/graph-session.js +29 -5
  34. package/dist/hook.js +84 -0
  35. package/dist/list-totals.js +52 -0
  36. package/dist/setup.js +5 -0
  37. package/docs/coverage.md +79 -13
  38. package/inventory/operations.json +2 -2
  39. package/package.json +4 -3
  40. package/skills/mg-axi/SKILL.md +56 -1
@@ -1,18 +1,29 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
2
  // READ-06: the Entra risky-user and risk-detection read mapping behind
3
3
  // `mg-axi entra risky-user list/show` and `mg-axi entra risk-detection
4
- // list/show`. Operation construction stays beside its command; the shared
4
+ // list/show`, extended with the workload-identity surface behind
5
+ // `mg-axi entra risky-service-principal list/show`,
6
+ // `mg-axi entra risky-service-principal history list/show` and
7
+ // `mg-axi entra service-principal-risk-detection list/show`. Operation construction stays beside its command; the shared
5
8
  // session owns URLs, credentials, paging, retries and error translation, and
6
9
  // the SDK owns TOON rendering. This module only maps flags to session calls,
7
10
  // bounds detection queries in time, and projects rows for compact output.
8
11
  //
9
12
  // Reviewed against the v1.0 riskyuser-list/get and riskdetection-list/get
10
- // operation documentation on 2026-10-04. Risky-user reads take D/A
13
+ // operation documentation on 2026-10-04, and against the v1.0
14
+ // riskyserviceprincipal-list/get, history-list and
15
+ // serviceprincipalriskdetection-list/get operation documentation on
16
+ // 2026-10-06. Risky-user reads take D/A
11
17
  // IdentityRiskyUser.Read.All and risk-detection reads take D/A
12
- // IdentityRiskEvent.Read.All; delegated callers additionally need Global
18
+ // IdentityRiskEvent.Read.All; risky-service-principal reads (including
19
+ // history) take D/A IdentityRiskyServicePrincipal.Read.All while
20
+ // service-principal risk detections reuse D/A IdentityRiskEvent.Read.All.
21
+ // Delegated callers additionally need Global
13
22
  // Reader, Security Operator, Security Reader or Security Administrator. The
14
23
  // riskyUsers API requires a P2 licence, while risk detection permits P1 or
15
- // P2. Limited views stay limited: a premium detection without P2 detail
24
+ // P2; the workload-identity APIs require a Microsoft Entra Workload
25
+ // Identities Premium licence, and service-principal detections report
26
+ // riskDetail and riskLevel hidden without it. Limited views stay limited: a premium detection without P2 detail
16
27
  // reports riskEventType generic, hidden risk levels report the licence
17
28
  // boundary instead of the level, and a null detection correlationId means no
18
29
  // sign-in is associated. None of these is ever reinterpreted as empty or
@@ -54,8 +65,67 @@ export const KNOWN_RISK_DETECTION_FIELDS = [
54
65
  "userPrincipalName",
55
66
  "additionalInfo",
56
67
  ];
68
+ // Workload-identity surface. History items inherit every risky-service-
69
+ // principal property and add the service-principal identifier plus the
70
+ // actor/activity of the risk change. Service-principal detections carry the
71
+ // service-principal join keys (servicePrincipalId, servicePrincipalDisplayName,
72
+ // appId, keyIds) instead of user keys; keyIds are key-credential identifiers,
73
+ // never secret material. tokenIssuerType stays absent for the same redaction
74
+ // reason as above.
75
+ export const KNOWN_RISKY_SP_FIELDS = [
76
+ "id",
77
+ "isEnabled",
78
+ "isProcessing",
79
+ "riskDetail",
80
+ "riskLastUpdatedDateTime",
81
+ "riskLevel",
82
+ "riskState",
83
+ "displayName",
84
+ "appId",
85
+ "servicePrincipalType",
86
+ ];
87
+ export const KNOWN_RISKY_SP_HISTORY_FIELDS = [
88
+ "id",
89
+ "isEnabled",
90
+ "isProcessing",
91
+ "riskDetail",
92
+ "riskLastUpdatedDateTime",
93
+ "riskLevel",
94
+ "riskState",
95
+ "displayName",
96
+ "appId",
97
+ "servicePrincipalType",
98
+ "servicePrincipalId",
99
+ "initiatedBy",
100
+ "activity",
101
+ ];
102
+ export const KNOWN_SP_DETECTION_FIELDS = [
103
+ "id",
104
+ "requestId",
105
+ "correlationId",
106
+ "riskEventType",
107
+ "riskState",
108
+ "riskLevel",
109
+ "riskDetail",
110
+ "source",
111
+ "detectionTimingType",
112
+ "activity",
113
+ "ipAddress",
114
+ "location",
115
+ "activityDateTime",
116
+ "detectedDateTime",
117
+ "lastUpdatedDateTime",
118
+ "servicePrincipalId",
119
+ "servicePrincipalDisplayName",
120
+ "appId",
121
+ "keyIds",
122
+ "additionalInfo",
123
+ ];
57
124
  const RISKY_USER_KNOWN = new Set(KNOWN_RISKY_USER_FIELDS);
58
125
  const RISK_DETECTION_KNOWN = new Set(KNOWN_RISK_DETECTION_FIELDS);
126
+ const RISKY_SP_KNOWN = new Set(KNOWN_RISKY_SP_FIELDS);
127
+ const RISKY_SP_HISTORY_KNOWN = new Set(KNOWN_RISKY_SP_HISTORY_FIELDS);
128
+ const SP_DETECTION_KNOWN = new Set(KNOWN_SP_DETECTION_FIELDS);
59
129
  // Compact list rows: identifier, who is at risk, and the triage state.
60
130
  const DEFAULT_RISKY_USER_LIST_SELECT = ["id", "userPrincipalName", "riskLevel", "riskState"];
61
131
  const DEFAULT_RISK_DETECTION_LIST_SELECT = ["id", "detectedDateTime", "userPrincipalName", "riskLevel"];
@@ -64,6 +134,7 @@ const DEFAULT_RISKY_USER_SHOW_SELECT = [...KNOWN_RISKY_USER_FIELDS];
64
134
  const DEFAULT_RISK_DETECTION_SHOW_SELECT = [...KNOWN_RISK_DETECTION_FIELDS];
65
135
  export const DEFAULT_RISKY_USER_SCOPES = ["https://graph.microsoft.com/IdentityRiskyUser.Read.All"];
66
136
  export const DEFAULT_RISK_DETECTION_SCOPES = ["https://graph.microsoft.com/IdentityRiskEvent.Read.All"];
137
+ export const DEFAULT_RISKY_SP_SCOPES = ["https://graph.microsoft.com/IdentityRiskyServicePrincipal.Read.All"];
67
138
  const TRUNCATE_AT = 500;
68
139
  function fieldList(raw, flag, known, knownList, help) {
69
140
  const parts = String(raw)
@@ -222,6 +293,23 @@ const RISK_DETECTION_DENIAL_HINTS = [
222
293
  "Risk-detection reads need IdentityRiskEvent.Read.All plus a supported directory role: Global Reader, Security Operator, Security Reader or Security Administrator for delegated access, or admin-consented IdentityRiskEvent.Read.All for application access",
223
294
  "Risk detection needs a P1 or P2 licence; premium detections report riskEventType generic without P2 detail, so generic is the server's limited view rather than the real type",
224
295
  ];
296
+ const RISKY_SP_DENIAL_HINTS = [
297
+ "Risky-service-principal reads need IdentityRiskyServicePrincipal.Read.All plus a supported directory role: Global Reader, Security Operator, Security Reader or Security Administrator for delegated access, or admin-consented IdentityRiskyServicePrincipal.Read.All for application access",
298
+ "The riskyServicePrincipals API requires a Microsoft Entra Workload Identities Premium licence; data availability follows Entra retention policies",
299
+ ];
300
+ const SP_DETECTION_DENIAL_HINTS = [
301
+ "Service-principal-risk-detection reads need IdentityRiskEvent.Read.All plus a supported directory role: Global Reader, Security Operator, Security Reader or Security Administrator for delegated access, or admin-consented IdentityRiskEvent.Read.All for application access",
302
+ "The servicePrincipalRiskDetection API requires a Microsoft Entra Workload Identities Premium licence; riskDetail and riskLevel report hidden without it, so hidden is the server's limited view rather than no risk",
303
+ ];
304
+ // Nested history routes bind the parent risky-service-principal id before
305
+ // credentials; a missing or empty parent fails as usage, never as Graph 404.
306
+ function parentServicePrincipal(flags, help) {
307
+ const parent = flags["service-principal"];
308
+ if (parent === undefined || !String(parent).trim()) {
309
+ throw new AxiError("--service-principal needs the risky-service-principal object ID owning this history", "VALIDATION_ERROR", [help]);
310
+ }
311
+ return { "riskyServicePrincipal-id": String(parent) };
312
+ }
225
313
  const RISKY_USER_LIST = {
226
314
  noun: "risky-user",
227
315
  key: "riskyUsers",
@@ -257,6 +345,62 @@ const RISK_DETECTION_LIST = {
257
345
  "Limited views stay limited: riskEventType generic can hide a premium detection without P2 detail, and hidden risk levels report the licence boundary instead of the level - never read them as none",
258
346
  ],
259
347
  };
348
+ // Compact workload rows: identifier, which workload, and the triage state.
349
+ const DEFAULT_RISKY_SP_LIST_SELECT = ["id", "displayName", "riskLevel", "riskState"];
350
+ const DEFAULT_SP_DETECTION_LIST_SELECT = ["id", "detectedDateTime", "servicePrincipalDisplayName", "riskLevel"];
351
+ const DEFAULT_RISKY_SP_SHOW_SELECT = [...KNOWN_RISKY_SP_FIELDS];
352
+ const DEFAULT_RISKY_SP_HISTORY_SHOW_SELECT = [...KNOWN_RISKY_SP_HISTORY_FIELDS];
353
+ const DEFAULT_SP_DETECTION_SHOW_SELECT = [...KNOWN_SP_DETECTION_FIELDS];
354
+ const RISKY_SP_LIST = {
355
+ noun: "risky-service-principal",
356
+ key: "riskyServicePrincipals",
357
+ known: RISKY_SP_KNOWN,
358
+ knownList: KNOWN_RISKY_SP_FIELDS,
359
+ defaultSelect: DEFAULT_RISKY_SP_LIST_SELECT,
360
+ defaultScopes: DEFAULT_RISKY_SP_SCOPES,
361
+ denialHints: RISKY_SP_DENIAL_HINTS,
362
+ showHint: "mg-axi entra risky-service-principal show --id <risky-service-principal-id>",
363
+ emptyHints: profileName => [
364
+ `mg-axi entra risky-service-principal list --filter <odata-filter> ${profileHint(profileName)}`,
365
+ "0 risky service principals matched; the absence of results is the answer, not an error",
366
+ "Limited results stay limited: without Workload Identities Premium detail the service reports what the caller may see, so never read a short list as no risk",
367
+ ],
368
+ extraHelp: () => [],
369
+ };
370
+ const RISKY_SP_HISTORY_LIST = {
371
+ noun: "risky-service-principal history",
372
+ key: "riskyServicePrincipalHistory",
373
+ pathParams: parentServicePrincipal,
374
+ known: RISKY_SP_HISTORY_KNOWN,
375
+ knownList: KNOWN_RISKY_SP_HISTORY_FIELDS,
376
+ defaultSelect: DEFAULT_RISKY_SP_LIST_SELECT,
377
+ defaultScopes: DEFAULT_RISKY_SP_SCOPES,
378
+ denialHints: RISKY_SP_DENIAL_HINTS,
379
+ showHint: "mg-axi entra risky-service-principal history show --service-principal <risky-service-principal-id> --id <history-item-id>",
380
+ emptyHints: profileName => [
381
+ `mg-axi entra risky-service-principal history list --service-principal <risky-service-principal-id> ${profileHint(profileName)}`,
382
+ "0 history items matched; the absence of results is the answer, not an error",
383
+ ],
384
+ extraHelp: () => [],
385
+ };
386
+ const SP_DETECTION_LIST = {
387
+ noun: "service-principal-risk-detection",
388
+ key: "servicePrincipalRiskDetections",
389
+ dateField: "detectedDateTime",
390
+ known: SP_DETECTION_KNOWN,
391
+ knownList: KNOWN_SP_DETECTION_FIELDS,
392
+ defaultSelect: DEFAULT_SP_DETECTION_LIST_SELECT,
393
+ defaultScopes: DEFAULT_RISK_DETECTION_SCOPES,
394
+ denialHints: SP_DETECTION_DENIAL_HINTS,
395
+ showHint: "mg-axi entra service-principal-risk-detection show --id <service-principal-risk-detection-id>",
396
+ emptyHints: profileName => [
397
+ `mg-axi entra service-principal-risk-detection list --since <earlier-iso-time> ${profileHint(profileName)}`,
398
+ "0 service-principal risk detections matched in this window; widen --since/--until or loosen --filter - the absence of results is the answer, not an error",
399
+ ],
400
+ extraHelp: () => [
401
+ "Limited views stay limited: riskDetail and riskLevel report hidden without Workload Identities Premium detail - never read hidden as none",
402
+ ],
403
+ };
260
404
  async function listRisk(shape, session, flags, profile, operation, help, profileName) {
261
405
  const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
262
406
  if (cursor !== undefined && !cursor.trim())
@@ -292,6 +436,8 @@ async function listRisk(shape, session, flags, profile, operation, help, profile
292
436
  query.$filter = String(flags.filter);
293
437
  }
294
438
  const args = { profile, operation, query, scopes };
439
+ if (shape.pathParams !== undefined)
440
+ args.params = shape.pathParams(flags, help);
295
441
  if (cursor !== undefined)
296
442
  args.cursor = cursor;
297
443
  if (flags.all === true) {
@@ -363,6 +509,50 @@ const RISK_DETECTION_SHOW = {
363
509
  ],
364
510
  noSignInNote: "This detection carries no associated sign-in (correlationId is null); sign-in correlation does not apply",
365
511
  };
512
+ const RISKY_SP_SHOW = {
513
+ noun: "risky-service-principal",
514
+ key: "riskyServicePrincipal",
515
+ param: "riskyServicePrincipal-id",
516
+ known: RISKY_SP_KNOWN,
517
+ knownList: KNOWN_RISKY_SP_FIELDS,
518
+ defaultSelect: DEFAULT_RISKY_SP_SHOW_SELECT,
519
+ defaultScopes: DEFAULT_RISKY_SP_SCOPES,
520
+ denialHints: RISKY_SP_DENIAL_HINTS,
521
+ correlationHelp: profileName => [
522
+ `List this workload's detections with mg-axi entra service-principal-risk-detection list --since <iso-time> --filter "servicePrincipalId eq '<risky-service-principal-id>'" ${profileHint(profileName)}`,
523
+ `List this workload's risk history with mg-axi entra risky-service-principal history list --service-principal <risky-service-principal-id> ${profileHint(profileName)}`,
524
+ "Confirming compromise and dismissing risk are separately reviewed writes; this read never confirms, dismisses or remediates risk",
525
+ ],
526
+ };
527
+ const RISKY_SP_HISTORY_SHOW = {
528
+ noun: "risky-service-principal history",
529
+ key: "riskyServicePrincipalHistoryItem",
530
+ param: "riskyServicePrincipalHistoryItem-id",
531
+ parentParams: parentServicePrincipal,
532
+ known: RISKY_SP_HISTORY_KNOWN,
533
+ knownList: KNOWN_RISKY_SP_HISTORY_FIELDS,
534
+ defaultSelect: DEFAULT_RISKY_SP_HISTORY_SHOW_SELECT,
535
+ defaultScopes: DEFAULT_RISKY_SP_SCOPES,
536
+ denialHints: RISKY_SP_DENIAL_HINTS,
537
+ correlationHelp: profileName => [
538
+ `List this workload's detections with mg-axi entra service-principal-risk-detection list --since <iso-time> --filter "servicePrincipalId eq '<risky-service-principal-id>'" ${profileHint(profileName)}`,
539
+ ],
540
+ };
541
+ const SP_DETECTION_SHOW = {
542
+ noun: "service-principal-risk-detection",
543
+ key: "servicePrincipalRiskDetection",
544
+ param: "servicePrincipalRiskDetection-id",
545
+ known: SP_DETECTION_KNOWN,
546
+ knownList: KNOWN_SP_DETECTION_FIELDS,
547
+ defaultSelect: DEFAULT_SP_DETECTION_SHOW_SELECT,
548
+ defaultScopes: DEFAULT_RISK_DETECTION_SCOPES,
549
+ denialHints: SP_DETECTION_DENIAL_HINTS,
550
+ correlationHelp: profileName => [
551
+ `List this workload's detections with mg-axi entra service-principal-risk-detection list --since <iso-time> --filter "servicePrincipalId eq '<service-principal-id>'" ${profileHint(profileName)}`,
552
+ "Limited views stay limited: riskDetail and riskLevel report hidden without Workload Identities Premium detail - never read hidden as none",
553
+ ],
554
+ noSignInNote: "This detection carries no associated sign-in (correlationId is null); sign-in correlation does not apply",
555
+ };
366
556
  async function showRisk(shape, session, flags, profile, operation, help, profileName) {
367
557
  const { select, fields } = selectedFields(flags, shape.defaultSelect, shape.known, shape.knownList, help);
368
558
  const scopes = scopesFor(flags, profile, shape.defaultScopes, help);
@@ -370,7 +560,7 @@ async function showRisk(shape, session, flags, profile, operation, help, profile
370
560
  const raw = await withGuidance(shape.denialHints, () => session.execute({
371
561
  profile,
372
562
  operation,
373
- params: { [shape.param]: String(flags.id) },
563
+ params: { ...(shape.parentParams === undefined ? {} : shape.parentParams(flags, help)), [shape.param]: String(flags.id) },
374
564
  query: { $select: select.join(",") },
375
565
  scopes,
376
566
  }));
@@ -402,3 +592,21 @@ export async function listRiskDetections(session, flags, profile, operation, hel
402
592
  export async function showRiskDetection(session, flags, profile, operation, help, profileName) {
403
593
  return showRisk(RISK_DETECTION_SHOW, session, flags, profile, operation, help, profileName);
404
594
  }
595
+ export async function listRiskyServicePrincipals(session, flags, profile, operation, help, profileName) {
596
+ return listRisk(RISKY_SP_LIST, session, flags, profile, operation, help, profileName);
597
+ }
598
+ export async function showRiskyServicePrincipal(session, flags, profile, operation, help, profileName) {
599
+ return showRisk(RISKY_SP_SHOW, session, flags, profile, operation, help, profileName);
600
+ }
601
+ export async function listRiskyServicePrincipalHistory(session, flags, profile, operation, help, profileName) {
602
+ return listRisk(RISKY_SP_HISTORY_LIST, session, flags, profile, operation, help, profileName);
603
+ }
604
+ export async function showRiskyServicePrincipalHistory(session, flags, profile, operation, help, profileName) {
605
+ return showRisk(RISKY_SP_HISTORY_SHOW, session, flags, profile, operation, help, profileName);
606
+ }
607
+ export async function listServicePrincipalRiskDetections(session, flags, profile, operation, help, profileName) {
608
+ return listRisk(SP_DETECTION_LIST, session, flags, profile, operation, help, profileName);
609
+ }
610
+ export async function showServicePrincipalRiskDetection(session, flags, profile, operation, help, profileName) {
611
+ return showRisk(SP_DETECTION_SHOW, session, flags, profile, operation, help, profileName);
612
+ }
@@ -1,4 +1,5 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // READ-09: the Entra directory-role and PIM read mapping behind
3
4
  // `mg-axi entra directory-role list/show`, `mg-axi entra role-assignment
4
5
  // list`, `mg-axi entra pim eligible list` and `mg-axi entra pim active list`.
@@ -177,6 +178,7 @@ const ACTIVE_DENIAL_HINTS = [
177
178
  const ROLE_LIST = {
178
179
  command: "entra directory-role list",
179
180
  key: "directoryRoles",
181
+ noun: "directory roles",
180
182
  known: ROLE_KNOWN,
181
183
  knownList: KNOWN_ROLE_FIELDS,
182
184
  defaultSelect: DEFAULT_ROLE_LIST_SELECT,
@@ -188,6 +190,7 @@ const ROLE_LIST = {
188
190
  const ASSIGNMENT_LIST = {
189
191
  command: "entra role-assignment list",
190
192
  key: "roleAssignments",
193
+ noun: "role assignments",
191
194
  known: ASSIGNMENT_KNOWN,
192
195
  knownList: KNOWN_ASSIGNMENT_FIELDS,
193
196
  defaultSelect: DEFAULT_ASSIGNMENT_SELECT,
@@ -199,6 +202,7 @@ const ASSIGNMENT_LIST = {
199
202
  const PIM_ELIGIBLE = {
200
203
  command: "entra pim eligible list",
201
204
  key: "eligibleAssignments",
205
+ noun: "eligible assignments",
202
206
  known: ELIGIBLE_KNOWN,
203
207
  knownList: KNOWN_ELIGIBLE_FIELDS,
204
208
  defaultSelect: DEFAULT_ELIGIBLE_SELECT,
@@ -210,6 +214,7 @@ const PIM_ELIGIBLE = {
210
214
  const PIM_ACTIVE = {
211
215
  command: "entra pim active list",
212
216
  key: "activeAssignments",
217
+ noun: "active assignments",
213
218
  known: ACTIVE_KNOWN,
214
219
  knownList: KNOWN_ACTIVE_FIELDS,
215
220
  defaultSelect: DEFAULT_ACTIVE_SELECT,
@@ -264,16 +269,17 @@ async function listCollection(shape, session, flags, profile, operation, help, p
264
269
  if (!result.complete) {
265
270
  return {
266
271
  [shape.key]: rows,
267
- count: { returned: rows.length, complete: false, reason: result.reason },
272
+ ...listTotals(rows.length, result.total, shape.noun, false),
273
+ complete: false,
274
+ reason: result.reason,
268
275
  cursor: result.cursor,
269
276
  help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, ...standing],
270
277
  };
271
278
  }
272
- const count = { returned: rows.length, complete: true };
273
279
  if (!rows.length) {
274
- return { [shape.key]: rows, count, help: [shape.emptyNote, ...standing] };
280
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.noun, true), complete: true, help: [shape.emptyNote, ...standing] };
275
281
  }
276
- return { [shape.key]: rows, count, help: [...truncationHints, ...standing] };
282
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.noun, true), complete: true, help: [...truncationHints, ...standing] };
277
283
  }
278
284
  export async function listDirectoryRoles(session, flags, profile, operation, help, profileName) {
279
285
  return listCollection(ROLE_LIST, session, flags, profile, operation, help, profileName);
@@ -1,5 +1,6 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
2
  import { resolveSessionOperation } from "./graph-session.js";
3
+ import { listTotals } from "./list-totals.js";
3
4
  // EXT-01 directory-subscriptions subfamily: the read mapping behind
4
5
  // `mg-axi entra subscription list/show/count`. Operation construction stays
5
6
  // beside its command; the shared session owns URLs, credentials, paging,
@@ -242,17 +243,18 @@ export async function listSubscriptions(session, flags, profile, operation, help
242
243
  if (!result.complete) {
243
244
  return {
244
245
  subscriptions,
245
- count: { returned: subscriptions.length, complete: false, reason: result.reason },
246
+ ...listTotals(subscriptions.length, result.total, "subscriptions", false),
247
+ complete: false,
248
+ reason: result.reason,
246
249
  cursor: result.cursor,
247
250
  help: [...truncationHints, resumeHint(profileName), showHint],
248
251
  };
249
252
  }
250
- const count = { returned: subscriptions.length, complete: true };
251
253
  if (!subscriptions.length) {
252
254
  const emptyHint = common.filter === undefined ? EMPTY_NOTE : "0 subscriptions matched; the absence of results is the answer, not an error";
253
- return { subscriptions, count, help: [emptyHint] };
255
+ return { subscriptions, ...listTotals(subscriptions.length, result.total, "subscriptions", true), complete: true, help: [emptyHint] };
254
256
  }
255
- return { subscriptions, count, help: [...truncationHints, showHint] };
257
+ return { subscriptions, ...listTotals(subscriptions.length, result.total, "subscriptions", true), complete: true, help: [...truncationHints, showHint] };
256
258
  }
257
259
  export async function showSubscription(session, flags, profile, operation, help, profileName) {
258
260
  const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
@@ -1,4 +1,5 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // READ-01: the Entra user read mapping behind `mg-axi entra user list/show`.
3
4
  // Operation construction stays beside its command; the shared session owns
4
5
  // URLs, credentials, paging, retries and error translation, and the SDK owns
@@ -169,16 +170,18 @@ export async function listUsers(session, flags, profile, operation, help, profil
169
170
  if (!result.complete) {
170
171
  return {
171
172
  users,
172
- count: { returned: users.length, complete: false, reason: result.reason },
173
+ ...listTotals(users.length, result.total, "users", false),
174
+ complete: false,
175
+ reason: result.reason,
173
176
  cursor: result.cursor,
174
177
  help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, showHint],
175
178
  };
176
179
  }
177
- const count = { returned: users.length, complete: true };
178
180
  if (!users.length) {
179
181
  return {
180
182
  users,
181
- count,
183
+ ...listTotals(users.length, result.total, "users", true),
184
+ complete: true,
182
185
  help: [
183
186
  `mg-axi entra user list --filter <odata-filter> ${profileHint(profileName)}`,
184
187
  "0 users matched; the absence of results is the answer, not an error",
@@ -186,7 +189,7 @@ export async function listUsers(session, flags, profile, operation, help, profil
186
189
  };
187
190
  }
188
191
  const helpHints = [...truncationHints, showHint];
189
- return { users, count, help: helpHints };
192
+ return { users, ...listTotals(users.length, result.total, "users", true), complete: true, help: helpHints };
190
193
  }
191
194
  export async function showUser(session, flags, profile, operation, help, profileName) {
192
195
  const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
@@ -38,6 +38,7 @@ const READ_SCOPES = new Set([
38
38
  "GroupMember.ReadBasic.All",
39
39
  "IdentityProvider.Read.All",
40
40
  "IdentityRiskEvent.Read.All",
41
+ "IdentityRiskyServicePrincipal.Read.All",
41
42
  "IdentityRiskyUser.Read.All",
42
43
  "LicenseAssignment.Read.All",
43
44
  "LifecycleWorkflows-Reports.Read.All",
@@ -54,8 +55,10 @@ const READ_SCOPES = new Set([
54
55
  "Policy.Read.All",
55
56
  "Policy.Read.AuthenticationMethod",
56
57
  "Policy.Read.ConditionalAccess",
58
+ "PrivilegedAssignmentSchedule.Read.AzureADGroup",
57
59
  "PrivilegedEligibilitySchedule.Read.AzureADGroup",
58
60
  "PublicKeyInfrastructure.Read.All",
61
+ "RiskPreventionProviders.Read.All",
59
62
  "RoleAssignmentSchedule.Read.Directory",
60
63
  "RoleEligibilitySchedule.Read.Directory",
61
64
  "RoleManagement.Read.Directory",
@@ -551,7 +554,7 @@ function bodyBytes(body) {
551
554
  return Buffer.byteLength(body, "utf8");
552
555
  }
553
556
  function encodeCursor(operation, state) {
554
- const payload = { v: 3, op: operation.id, next: state.next ?? null, buffered: state.buffered, seen: state.seen.slice(-MAX_SEEN), query: state.query, consistencyLevel: state.consistencyLevel ?? null, context: state.context, identity: state.identity };
557
+ const payload = { v: 3, op: operation.id, next: state.next ?? null, buffered: state.buffered, seen: state.seen.slice(-MAX_SEEN), query: state.query, consistencyLevel: state.consistencyLevel ?? null, context: state.context, identity: state.identity, total: state.total };
555
558
  return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
556
559
  }
557
560
  function decodeCursor(operation, cursor) {
@@ -574,13 +577,17 @@ function decodeCursor(operation, cursor) {
574
577
  }
575
578
  if (record.buffered.length > 5000)
576
579
  throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["The cursor buffers at most one fetched page"]);
580
+ const total = record.total === undefined ? null : record.total;
581
+ if (!(total === null || (typeof total === "number" && Number.isSafeInteger(total) && total >= 0))) {
582
+ throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["Resume with the cursor from a partial result of the same operation"]);
583
+ }
577
584
  if (record.identity === null && (record.buffered.length > 0 || record.seen.length > 0))
578
585
  throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["Fetched rows and consumed pages require a bound credential identity"]);
579
586
  const query = record.query;
580
587
  const consistencyLevel = record.consistencyLevel ?? undefined;
581
588
  buildQuery(query);
582
589
  checkQueryContext(query, consistencyLevel);
583
- return { next: record.next ?? undefined, buffered: record.buffered.map(redact), seen: [...record.seen], query, consistencyLevel, context: record.context, identity: record.identity };
590
+ return { next: record.next ?? undefined, buffered: record.buffered.map(redact), seen: [...record.seen], query, consistencyLevel, context: record.context, identity: record.identity, total };
584
591
  }
585
592
  function nextLinkOf(body) {
586
593
  if (!body || typeof body !== "object" || Array.isArray(body))
@@ -588,6 +595,16 @@ function nextLinkOf(body) {
588
595
  const value = body["@odata.nextLink"];
589
596
  return typeof value === "string" && value ? value : undefined;
590
597
  }
598
+ // The total Graph already supplies on collection pages when the query
599
+ // carries $count=true. Only a valid non-negative integer becomes a total;
600
+ // anything else stays unknown rather than failing the read or inventing
601
+ // a number.
602
+ function countOf(body) {
603
+ if (!body || typeof body !== "object" || Array.isArray(body))
604
+ return null;
605
+ const count = body["@odata.count"];
606
+ return typeof count === "number" && Number.isSafeInteger(count) && count >= 0 ? count : null;
607
+ }
591
608
  function valuesOf(operation, body) {
592
609
  if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "value")) {
593
610
  throw new AxiError(`Graph returned a malformed collection body for ${operation.id}`, "GRAPH_ERROR", ["Collections carry a value array; treat anything else as unknown, not empty"]);
@@ -699,14 +716,18 @@ export class GraphSession {
699
716
  let requests = 0;
700
717
  let bytes = 0;
701
718
  let identity = resumed?.identity ?? null;
719
+ // First known server total wins across pages and resumes; later pages
720
+ // repeat the same query total, so disagreement keeps the first.
721
+ let total = resumed?.total ?? null;
702
722
  const partial = (reason, next, buffered) => ({
703
723
  value: results,
704
724
  query,
705
725
  complete: false,
706
726
  reason,
707
- cursor: encodeCursor(operation, { next, buffered, seen: [...seenList], query, consistencyLevel, context, identity }),
727
+ cursor: encodeCursor(operation, { next, buffered, seen: [...seenList], query, consistencyLevel, context, identity, total }),
708
728
  requests,
709
729
  bytes,
730
+ total,
710
731
  });
711
732
  let token;
712
733
  try {
@@ -744,11 +765,11 @@ export class GraphSession {
744
765
  return partial("deadline exceeded", current, pending);
745
766
  if (args.limit !== undefined && results.length >= args.limit) {
746
767
  if (pending.length === 0 && !current)
747
- return { value: results, query, complete: true, requests, bytes };
768
+ return { value: results, query, complete: true, requests, bytes, total };
748
769
  return partial("row limit reached; buffered remainder is preserved in the cursor", current, pending);
749
770
  }
750
771
  if (!current)
751
- return { value: results, query, complete: true, requests, bytes };
772
+ return { value: results, query, complete: true, requests, bytes, total };
752
773
  if (requests >= budget.maxRequests)
753
774
  return partial(`request budget exhausted after ${requests} requests`, current, pending);
754
775
  const cycleKey = digestUrl(current);
@@ -837,6 +858,9 @@ export class GraphSession {
837
858
  throw new AxiError(`Graph returned a non-JSON success body for ${operation.id}`, "GRAPH_ERROR", ["Successful reads are JSON; anything else is malformed"]);
838
859
  }
839
860
  const rows = valuesOf(operation, parsed);
861
+ const pageTotal = countOf(parsed);
862
+ if (total === null && pageTotal !== null)
863
+ total = pageTotal;
840
864
  const rawNext = nextLinkOf(parsed);
841
865
  const nextUrl = rawNext ? authorizeUrl(operation, params, rawNext, fetchUrl, consistencyLevel) : undefined;
842
866
  remember(current);
package/dist/hook.js ADDED
@@ -0,0 +1,84 @@
1
+ import { encode } from "@toon-format/toon";
2
+ import { Profiles } from "./profiles.js";
3
+ // Session-start ambient summary behind the mg-axi-hook entry point.
4
+ // Local configuration state only: every configured profile name with its
5
+ // tenant label, auth-cache presence and effective write posture, plus the
6
+ // package version and one next-step hint. No session, transport, Graph or
7
+ // sign-in call of any kind; every failure collapses to a short record with
8
+ // exit code 0, so a missing or broken config never breaks agent startup.
9
+ //
10
+ // Auth-cache presence reads the OS credential store directly: delegated
11
+ // profiles keep their MSAL session blob under the "mg-axi" service with the
12
+ // session key below, so presence means a previous explicit login happened on
13
+ // this machine. Only presence is reported; tokens and key material never
14
+ // reach the output. Application profiles acquire tokens silently per run, so
15
+ // they report the client-credentials mechanism instead of cache state.
16
+ // Session cache key mirrors MsalProvider.application() in
17
+ // src/msal-provider.ts; change both together.
18
+ function delegatedSessionKey(profile) {
19
+ return JSON.stringify([profile.credentialRef.key, profile.tenantId, profile.clientId, profile.cloud]);
20
+ }
21
+ async function probeAuthPresence(profile) {
22
+ if (profile.mode !== "delegated")
23
+ return "client-credentials";
24
+ let store;
25
+ try {
26
+ store = await import("keytar").then(module => module.default).catch(() => undefined);
27
+ }
28
+ catch {
29
+ return "unknown";
30
+ }
31
+ if (!store)
32
+ return "unknown";
33
+ try {
34
+ return typeof await store.getPassword("mg-axi", delegatedSessionKey(profile)) === "string" ? "cached" : "sign-in required";
35
+ }
36
+ catch {
37
+ return "unknown";
38
+ }
39
+ }
40
+ // Next-step hints name real catalogue leaves; test/hook.test.mjs asserts
41
+ // every `mg-axi ...` command mentioned here exists in the catalogue, so the
42
+ // hook text cannot drift from the single source.
43
+ const CONFIGURED_HINT = "Run npx -y @knowttl/mg-axi doctor --profile <name> to check a profile";
44
+ const SETUP_HINT = "Run npx -y @knowttl/mg-axi setup to find the config path and example";
45
+ export async function hookSummary(options) {
46
+ try {
47
+ return await renderHookSummary(options);
48
+ }
49
+ catch {
50
+ return "mg: status unavailable\n";
51
+ }
52
+ }
53
+ async function renderHookSummary({ version, env = process.env, configPath, authPresence = probeAuthPresence }) {
54
+ let store;
55
+ let names;
56
+ try {
57
+ store = new Profiles(configPath ?? env.MG_AXI_CONFIG);
58
+ names = store.list().map(item => item.name);
59
+ }
60
+ catch {
61
+ return `${encode({ mg: "configuration invalid", help: ["Check the selected config file's path, permissions and JSON syntax"] })}\n`;
62
+ }
63
+ if (names.length === 0) {
64
+ return `${encode({ mg: "not configured", help: [SETUP_HINT] })}\n`;
65
+ }
66
+ const profiles = [];
67
+ for (const name of names) {
68
+ const profile = store.resolve(name).profile;
69
+ let auth = "unknown";
70
+ try {
71
+ auth = await authPresence(profile);
72
+ }
73
+ catch {
74
+ auth = "unknown";
75
+ }
76
+ profiles.push({
77
+ name,
78
+ tenant: profile.tenantId,
79
+ auth,
80
+ writes: profile.writes?.allowWrites === true ? profile.writes.operations.join(",") : "disabled",
81
+ });
82
+ }
83
+ return `${encode({ mg: "configured", version, profiles, help: [CONFIGURED_HINT] })}\n`;
84
+ }
@@ -0,0 +1,52 @@
1
+ // LIST-TOTALS: the one shared helper behind every list command's uniform
2
+ // "N of M" total line (AXI principle: pre-computed aggregates).
3
+ //
4
+ // A list line names rows returned of total available. The total is the one
5
+ // Graph already supplies on the fetched pages (@odata.count, captured by the
6
+ // shared session when the query carries $count=true); it is never fetched
7
+ // with an extra request and never invented. Where the pages carry no usable
8
+ // total, the line stays honest: a complete read names its rows, a partial
9
+ // read says more is available.
10
+ //
11
+ // The shape matches az-axi's countLine and vectra-axi's collection counts:
12
+ // a `total` field (number when known, null when not) beside a `count`
13
+ // string. The noun is always plural ("groups", never "group"); the helper
14
+ // never inflects, so "1 groups" reads exactly like az-axi's "1 of N" lines.
15
+ import { AxiError } from "axi-sdk-js";
16
+ function checkShown(shown) {
17
+ if (!Number.isSafeInteger(shown) || shown < 0) {
18
+ throw new AxiError(`Invalid list row count ${shown}`, "VALIDATION_ERROR", [
19
+ "List totals count the projected rows actually returned",
20
+ ]);
21
+ }
22
+ }
23
+ function checkTotal(total) {
24
+ if (total === undefined)
25
+ return null;
26
+ if (total === null)
27
+ return null;
28
+ if (!Number.isSafeInteger(total) || total < 0) {
29
+ throw new AxiError(`Invalid list total ${total}`, "VALIDATION_ERROR", [
30
+ "A list total is the server-supplied @odata.count or null when unknown; never fabricate one",
31
+ ]);
32
+ }
33
+ return total;
34
+ }
35
+ export function listTotals(shown, total, noun, complete) {
36
+ checkShown(shown);
37
+ const known = checkTotal(total);
38
+ if (typeof noun !== "string" || !noun) {
39
+ throw new AxiError("Invalid list noun for totals", "VALIDATION_ERROR", [
40
+ "Name the listed collection in the plural, e.g. groups, members, memberships",
41
+ ]);
42
+ }
43
+ // A partial read always names more: the known total stays visible even
44
+ // when the window happens to match it (az-axi countLine parity), while
45
+ // an unknown total says more is available. A complete read collapses a
46
+ // matching total to the row count.
47
+ if (known !== null && (known !== shown || !complete))
48
+ return { total: known, count: `${shown} of ${known} ${noun}` };
49
+ if (complete)
50
+ return { total: known, count: `${shown} ${noun}` };
51
+ return { total: known, count: `${shown} ${noun} shown, more available` };
52
+ }