@knowttl/mg-axi 0.1.4 → 0.1.6

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.
@@ -0,0 +1,65 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "mg-axi credential profile",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": ["mode", "tenantId", "clientId"],
7
+ "properties": {
8
+ "mode": { "enum": ["delegated", "application"] },
9
+ "tenantId": { "$ref": "#/$defs/uuid" },
10
+ "clientId": { "$ref": "#/$defs/uuid" },
11
+ "cloud": { "const": "commercial", "default": "commercial" },
12
+ "enabledPacks": { "type": "array", "items": { "const": "entra" }, "uniqueItems": true, "default": ["entra"] },
13
+ "preview": { "type": "boolean", "default": false },
14
+ "sensitiveAreas": { "type": "array", "maxItems": 0, "default": [] },
15
+ "allowDeviceCode": { "type": "boolean", "default": false },
16
+ "auth": { "enum": ["certificate", "client-secret", "federated"] },
17
+ "thumbprintSha256": { "type": "string", "pattern": "^[0-9a-fA-F]{64}$" },
18
+ "privateKey": { "$ref": "#/$defs/externalSecret", "x-secret": true },
19
+ "clientSecret": { "$ref": "#/$defs/externalSecret", "x-secret": true },
20
+ "token": { "$ref": "#/$defs/secret", "x-secret": true },
21
+ "writes": {
22
+ "type": "object", "additionalProperties": false, "required": ["allowWrites", "operations"],
23
+ "properties": {
24
+ "allowWrites": { "type": "boolean" },
25
+ "operations": { "type": "array", "minItems": 1, "maxItems": 64, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }
26
+ }
27
+ }
28
+ },
29
+ "oneOf": [
30
+ {
31
+ "properties": { "mode": { "const": "delegated" } },
32
+ "not": { "anyOf": [{ "required": ["auth"] }, { "required": ["privateKey"] }, { "required": ["clientSecret"] }, { "required": ["thumbprintSha256"] }] }
33
+ },
34
+ {
35
+ "required": ["auth", "thumbprintSha256"],
36
+ "properties": { "mode": { "const": "application" }, "auth": { "const": "certificate" }, "allowDeviceCode": { "const": false } },
37
+ "not": { "anyOf": [{ "required": ["token"] }, { "required": ["clientSecret"] }] }
38
+ },
39
+ {
40
+ "required": ["auth"],
41
+ "properties": { "mode": { "const": "application" }, "auth": { "const": "client-secret" }, "allowDeviceCode": { "const": false } },
42
+ "not": { "anyOf": [{ "required": ["token"] }, { "required": ["privateKey"] }, { "required": ["thumbprintSha256"] }] }
43
+ },
44
+ {
45
+ "required": ["auth"],
46
+ "properties": { "mode": { "const": "application" }, "auth": { "const": "federated" }, "token": { "$ref": "#/$defs/externalSecret" }, "allowDeviceCode": { "const": false } },
47
+ "not": { "anyOf": [{ "required": ["privateKey"] }, { "required": ["clientSecret"] }, { "required": ["thumbprintSha256"] }] }
48
+ }
49
+ ],
50
+ "$defs": {
51
+ "uuid": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" },
52
+ "externalSecret": {
53
+ "oneOf": [
54
+ { "type": "object", "additionalProperties": false, "required": ["env"], "properties": { "env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } } },
55
+ { "type": "object", "additionalProperties": false, "required": ["file"], "properties": { "file": { "type": "string", "pattern": "^(?:/|[A-Za-z]:[\\\\/])" } } }
56
+ ]
57
+ },
58
+ "secret": {
59
+ "oneOf": [
60
+ { "$ref": "#/$defs/externalSecret" },
61
+ { "type": "object", "additionalProperties": false, "required": ["stored"], "properties": { "stored": { "const": true } } }
62
+ ]
63
+ }
64
+ }
65
+ }
package/dist/app-auth.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { PrivateFiles } from "./private-file.js";
2
3
  import { validateApplicationProfile } from "./profiles.js";
4
+ import { registerSecret } from "./redact.js";
3
5
  // The configured Graph audience is the app's consented application grants, never a per-command subset.
4
6
  export const graphAudience = (profile) => `https://graph.microsoft.com/.default`;
5
7
  // This service is the application credential boundary for CORE-01; tokens never reach CLI output.
@@ -7,13 +9,15 @@ export const graphAudience = (profile) => `https://graph.microsoft.com/.default`
7
9
  export class ApplicationAuth {
8
10
  provider;
9
11
  now;
12
+ files;
10
13
  cache = new Map();
11
- constructor(provider, now = Date.now) {
14
+ constructor(provider, now = Date.now, files = new PrivateFiles()) {
12
15
  this.provider = provider;
13
16
  this.now = now;
17
+ this.files = files;
14
18
  }
15
19
  async credential(profile, scopes = [graphAudience(profile)]) {
16
- profile = validateApplicationProfile(profile);
20
+ profile = validateApplicationProfile(profile, this.files);
17
21
  const requested = this.audience(profile, scopes);
18
22
  const key = this.key(profile, requested);
19
23
  const hit = this.cache.get(key);
@@ -30,7 +34,7 @@ export class ApplicationAuth {
30
34
  catch (error) {
31
35
  // Provider AUTH_REQUIRED failures already name only the credential
32
36
  // reference, never its value; everything else stays fully generic.
33
- if (error instanceof AxiError && error.code === "AUTH_REQUIRED")
37
+ if (error instanceof AxiError && ["AUTH_REQUIRED", "CONFIG_INVALID", "CONFIG_INSECURE"].includes(error.code))
34
38
  throw error;
35
39
  throw this.failure();
36
40
  }
@@ -41,10 +45,11 @@ export class ApplicationAuth {
41
45
  throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", ["mg-axi profile show --profile <name>", "Application permissions apply to the app registration, not to individual commands"]);
42
46
  return configured;
43
47
  }
44
- key(profile, scopes) { return JSON.stringify([profile.credentialRef, profile.tenantId, profile.clientId, profile.cloud, scopes]); }
48
+ key(profile, scopes) { return JSON.stringify([profile.auth, profile.auth === "certificate" ? [profile.privateKey, profile.thumbprintSha256] : profile.auth === "client-secret" ? profile.clientSecret : profile.token, profile.tenantId, profile.clientId, profile.cloud, scopes]); }
45
49
  check(profile, credential) {
46
50
  if (!credential.token || credential.tenantId.toLowerCase() !== profile.tenantId.toLowerCase() || credential.clientId.toLowerCase() !== profile.clientId.toLowerCase() || !Number.isFinite(credential.expiresAt) || credential.expiresAt <= this.now() + 60_000)
47
51
  throw new Error("Invalid credential context");
52
+ registerSecret(credential.token);
48
53
  return { ...credential };
49
54
  }
50
55
  failure() { return new AxiError("Application authentication failed for the configured identity; no user or device-code fallback was attempted", "AUTH_REQUIRED", ["mg-axi profile show --profile <name>", "Ask an administrator to grant application consent for the Graph .default audience on this app registration", "Confirm the certificate thumbprint or federated credential mapping on the app registration"]); }
@@ -0,0 +1,20 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { Credentials } from "./credentials.js";
3
+ import { resolveSecret, secretSource } from "./secrets.js";
4
+ export function authStatus(name, profile, credentials = new Credentials()) {
5
+ const fields = profile.mode === "delegated" ? { token: profile.token }
6
+ : profile.auth === "certificate" ? { privateKey: profile.privateKey }
7
+ : profile.auth === "client-secret" ? { clientSecret: profile.clientSecret } : { token: profile.token };
8
+ const sources = Object.entries(fields).map(([field, ref]) => {
9
+ let available = false;
10
+ try {
11
+ available = "stored" in ref && profile.mode === "delegated" ? credentials.read(profile, true) !== undefined : !!resolveSecret(ref, credentials.files, true);
12
+ }
13
+ catch (error) {
14
+ if (!(error instanceof AxiError) || error.code !== "AUTH_REQUIRED")
15
+ throw error;
16
+ }
17
+ return { field, source: "stored" in ref ? `stored:${credentials.path}` : secretSource(ref), available };
18
+ });
19
+ return { profile: name, mode: profile.mode, endpoint: "https://graph.microsoft.com", tenantId: profile.tenantId, clientId: profile.clientId, sources };
20
+ }
package/dist/auth.js CHANGED
@@ -1,18 +1,22 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
- import { validateDelegatedProfile } from "./profiles.js";
2
+ import { PrivateFiles } from "./private-file.js";
3
+ import { profileName, validateDelegatedProfile } from "./profiles.js";
4
+ import { registerSecret } from "./redact.js";
3
5
  // This service is the credential boundary for CORE-01; tokens never reach CLI output.
4
6
  export class DelegatedAuth {
5
7
  provider;
6
8
  now;
9
+ files;
7
10
  cache = new Map();
8
- constructor(provider, now = Date.now) {
11
+ constructor(provider, now = Date.now, files = new PrivateFiles()) {
9
12
  this.provider = provider;
10
13
  this.now = now;
14
+ this.files = files;
11
15
  }
12
16
  async login(profile, method = "browser", scopes = []) {
13
- profile = validateDelegatedProfile(profile);
17
+ profile = validateDelegatedProfile(profile, this.files);
14
18
  if (method !== "browser" && method !== "device-code")
15
- throw new AxiError("Login method must be browser or device-code", "VALIDATION_ERROR", ["mg-axi login --help"]);
19
+ throw new AxiError("Login method must be browser or device-code", "VALIDATION_ERROR", ["mg-axi auth login --help"]);
16
20
  if (method === "device-code" && !profile.allowDeviceCode)
17
21
  throw new AxiError("Device code is disabled for this profile", "VALIDATION_ERROR", ["Enable device code only when organization policy permits it; browser login is the default"]);
18
22
  const requested = this.scopes(scopes);
@@ -23,12 +27,14 @@ export class DelegatedAuth {
23
27
  this.cache.set(key, credential);
24
28
  return { status: "authenticated", mode: "delegated", storage: this.provider.storage };
25
29
  }
26
- catch {
30
+ catch (error) {
31
+ if (error instanceof AxiError && ["CONFIG_INVALID", "CONFIG_INSECURE"].includes(error.code))
32
+ throw error;
27
33
  throw this.failure("LOGIN_FAILED");
28
34
  }
29
35
  }
30
36
  async credential(profile, scopes) {
31
- profile = validateDelegatedProfile(profile);
37
+ profile = validateDelegatedProfile(profile, this.files);
32
38
  const requested = this.scopes(scopes);
33
39
  const key = this.key(profile, requested);
34
40
  const hit = this.cache.get(key);
@@ -40,20 +46,28 @@ export class DelegatedAuth {
40
46
  this.cache.set(key, credential);
41
47
  return { ...credential };
42
48
  }
43
- catch {
49
+ catch (error) {
50
+ if (error instanceof AxiError && ["CONFIG_INVALID", "CONFIG_INSECURE"].includes(error.code))
51
+ throw error;
44
52
  throw this.failure("AUTH_REQUIRED");
45
53
  }
46
54
  }
55
+ async logout(profile) {
56
+ this.cache.clear();
57
+ await this.provider.logout(validateDelegatedProfile(profile, this.files));
58
+ return { status: "signed out", revocation: "MSAL provides no token revocation API; local credentials deleted" };
59
+ }
47
60
  scopes(scopes) {
48
61
  if (!scopes.length || scopes.some(scope => !/^https:\/\/graph\.microsoft\.com\/[A-Za-z][A-Za-z.-]+$/.test(scope) || scope.endsWith("/.default")))
49
- throw new AxiError("Explicit delegated Graph scopes are required", "VALIDATION_ERROR", ["mg-axi login --profile <name> --scopes <comma-separated-Graph-scopes>"]);
62
+ throw new AxiError("Explicit delegated Graph scopes are required", "VALIDATION_ERROR", ["mg-axi auth login --profile <name> --scopes <comma-separated-Graph-scopes>"]);
50
63
  return [...new Set(scopes)].sort();
51
64
  }
52
- key(profile, scopes) { return JSON.stringify([profile.credentialRef, profile.tenantId, profile.clientId, profile.cloud, scopes]); }
65
+ key(profile, scopes) { return JSON.stringify([profileName(profile), profile.token, profile.tenantId, profile.clientId, profile.cloud, scopes]); }
53
66
  check(profile, credential) {
54
67
  if (!credential.token || !credential.accountId || credential.tenantId.toLowerCase() !== profile.tenantId.toLowerCase() || credential.clientId.toLowerCase() !== profile.clientId.toLowerCase() || !Number.isFinite(credential.expiresAt) || credential.expiresAt <= this.now() + 60_000)
55
68
  throw new Error("Invalid credential context");
69
+ registerSecret(credential.token);
56
70
  return { ...credential };
57
71
  }
58
- failure(code) { return new AxiError("Authentication unavailable for the configured identity; no interactive fallback was attempted", code, ["mg-axi login --profile <name> --scopes <comma-separated-Graph-scopes>", "Check dedicated app consent and organization sign-in policy", "Restore OS credential store access before retrying login; prior stored accounts must be invalidated before replacement"]); }
72
+ failure(code) { return new AxiError("Authentication unavailable for the configured identity; no interactive fallback was attempted", code, ["mg-axi auth login --profile <name> --scopes <comma-separated-Graph-scopes>", "Check dedicated app consent and organization sign-in policy", "Restore access to the protected credentials.json before retrying login"]); }
59
73
  }
package/dist/catalogue.js CHANGED
@@ -506,23 +506,23 @@ export const LEAVES = [
506
506
  cloud: { value: "commercial", required: true, description: "Explicit cloud; only commercial is supported" },
507
507
  mode: { value: "delegated|application", default: "delegated", description: "Delegated analyst sign-in or application client credentials" },
508
508
  "allow-device-code": { description: "Delegated only: opt in only when permitted by organization policy; never enables fallback" },
509
- "certificate-thumbprint": { value: "40-hex-digits", description: "Application only: certificate thumbprint; the private key stays in referenced storage" },
510
- "certificate-key-file": { value: "path", description: "Application only: certificate PEM private key from this 0600 file; omit both key flags for the OS keychain" },
511
- "certificate-key-env": { value: "env-var-name", description: "Application only: certificate PEM private key from this environment variable; omit both key flags for the OS keychain" },
509
+ "certificate-thumbprint-sha256": { value: "64-hex-digits", description: "Application only: SHA-256 certificate thumbprint" },
510
+ "certificate-key-file": { value: "absolute-path", description: "Application only: certificate PEM private key from this protected file" },
511
+ "certificate-key-env": { value: "env-var-name", description: "Application only: certificate PEM private key from this environment variable; defaults to MG_AXI_PRIVATE_KEY" },
512
512
  "client-secret-env": { value: "env-var-name", description: "Application only: client secret from this environment variable; exactly one of this or --client-secret-file" },
513
- "client-secret-file": { value: "path", description: "Application only: client secret from this 0600 file; exactly one of this or --client-secret-env" },
514
- federated: { description: "Application only: workload federation via the assertion file named by --federated-token-file-env" },
515
- "federated-token-file-env": { value: "env-var-name", description: "Application only: environment variable naming the workload assertion file; defaults to AZURE_FEDERATED_TOKEN_FILE" },
516
- }, examples: ["mg-axi profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial", "mg-axi profile create --name batch --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --federated", "mg-axi profile create --name daemon --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --certificate-thumbprint <40-hex-digit-thumbprint> --certificate-key-file /run/secrets/mg-axi-key.pem", "mg-axi profile create --name svc --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --client-secret-env MG_AXI_CLIENT_SECRET", "mg-axi profile create --help"] },
513
+ "client-secret-file": { value: "absolute-path", description: "Application only: client secret from this protected file; exactly one of this or --client-secret-env" },
514
+ federated: { description: "Application only: workload federation; token defaults to env MG_AXI_TOKEN; AZURE_FEDERATED_TOKEN_FILE stays accepted as a deprecated alias" },
515
+ "federated-token-file": { value: "absolute-path", description: "Application only: workload assertion content from this protected file" },
516
+ }, examples: ["mg-axi profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial", "mg-axi profile create --name batch --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --federated", "mg-axi profile create --name daemon --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --certificate-thumbprint-sha256 <64-hex-digit-thumbprint> --certificate-key-file /run/secrets/mg-axi-key.pem", "mg-axi profile create --name svc --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --client-secret-env MG_AXI_CLIENT_SECRET", "mg-axi profile create --help"] },
517
517
  { path: "profile list", description: "List configured profiles without accessing credentials", flags: {}, examples: ["mg-axi profile list", "mg-axi profile list --help"] },
518
518
  { path: "profile show", description: "Show profile policy and credential reference, never credentials", flags: {
519
519
  profile: common.profile,
520
520
  }, examples: ["mg-axi profile show --profile soc", "mg-axi profile show"] },
521
- { path: "login", description: "Explicit delegated login; browser by default, no automatic device-code fallback", flags: {
521
+ { path: "auth login", description: "Explicit delegated login; browser by default, no automatic device-code fallback", flags: {
522
522
  profile: common.profile,
523
523
  method: { value: "browser|device-code", default: "browser", description: "Device code additionally requires profile opt-in" },
524
524
  scopes: { value: "comma-separated-Graph-scopes", required: true, description: "Explicit delegated permissions using full https://graph.microsoft.com/ scope names" },
525
- }, examples: ["mg-axi login --profile soc --scopes https://graph.microsoft.com/User.Read", "mg-axi login --profile soc --method device-code --scopes https://graph.microsoft.com/User.Read"] },
525
+ }, examples: ["mg-axi auth login --profile soc --scopes https://graph.microsoft.com/User.Read", "mg-axi auth login --profile soc --method device-code --scopes https://graph.microsoft.com/User.Read"] },
526
526
  { path: "setup", description: "Show installation, configuration and capability guidance without signing in or writing anything", flags: {}, examples: ["mg-axi setup", "mg-axi setup --help"] },
527
527
  { path: "setup hooks", description: "Install or repair agent SessionStart hooks for mg-axi ambient context", flags: {}, examples: ["mg-axi setup hooks", "mg-axi setup hooks --help"] },
528
528
  { path: "doctor", description: "Check each selected profile with one bounded user-list read; never signs in interactively, installs nothing, enables no writes", flags: {
@@ -2790,6 +2790,12 @@ export const LEAVES = [
2790
2790
  full: { description: "Disable 500-character string truncation; never disables redaction or row caps" },
2791
2791
  }, examples: ["mg-axi api get /users --scopes https://graph.microsoft.com/User.Read.All", "mg-axi api get /groups --odata '$filter=securityEnabled eq true&$top=5' --scopes https://graph.microsoft.com/GroupMember.Read.All", "mg-axi api get /users --cursor - --all --scopes https://graph.microsoft.com/User.Read.All < cursor.txt"] },
2792
2792
  ];
2793
+ LEAVES.push({ ...LEAVES.find(leaf => leaf.path === "auth login"), path: "login", description: "Deprecated alias for auth login (one minor release)" });
2794
+ LEAVES.push({ path: "config path", description: "Show the selected configuration path without reading credentials", flags: {}, examples: ["mg-axi config path", "mg-axi config path --config /absolute/path/config.json"] });
2795
+ LEAVES.push({ path: "auth status", description: "Show credential sources and verify local file permissions without a live request", flags: { profile: common.profile }, examples: ["mg-axi auth status", "mg-axi auth status --profile soc"] });
2796
+ LEAVES.push({ path: "auth logout", description: "Delete the selected delegated login cache without prompting", flags: { profile: common.profile }, examples: ["mg-axi auth logout", "mg-axi auth logout --profile soc"] });
2797
+ for (const leaf of LEAVES)
2798
+ leaf.flags.config = { value: "path", description: "Configuration file override; takes precedence over MG_AXI_CONFIG" };
2793
2799
  export function leafHelp(leaf) {
2794
2800
  return [
2795
2801
  `mg-axi ${leaf.path}${leaf.positional ? ` <${leaf.positional.name}>` : ""}`, leaf.description,
@@ -2828,7 +2834,7 @@ export function resolveCommand(argv) {
2828
2834
  const flags = Object.create(null);
2829
2835
  for (let i = flagStart; i < argv.length; i++) {
2830
2836
  const arg = argv[i];
2831
- const match = /^--([a-z-]+)(?:=(.*))?$/.exec(arg);
2837
+ const match = /^--([a-z][a-z0-9-]*)(?:=(.*))?$/.exec(arg);
2832
2838
  if (!match)
2833
2839
  fail("unexpected argument or short flag");
2834
2840
  const name = match[1];
package/dist/cli.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { encode } from "@toon-format/toon";
2
- import { existsSync } from "node:fs";
3
- import { dirname, join } from "node:path";
2
+ import { existsSync, realpathSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, relative, isAbsolute, sep } from "node:path";
4
5
  import { AxiError, installSessionStartHooks, runAxiCli } from "axi-sdk-js";
5
6
  import { LEAVES, home, leafHelp, operationFor, resolveCommand, DESCRIPTION, TOP_LEVEL_HELP } from "./catalogue.js";
6
7
  import { VERSION } from "./version.js";
7
8
  import { Profiles } from "./profiles.js";
9
+ import { authStatus } from "./auth-status.js";
10
+ import { redactSecrets } from "./redact.js";
8
11
  import { GraphSession, MAX_CURSOR_BYTES } from "./graph-session.js";
9
12
  import { listUsers, showUser } from "./entra-users.js";
10
13
  import { updateUserAccount } from "./entra-user-update.js";
@@ -48,22 +51,21 @@ import { addGroupMember } from "./entra-group-member-add.js";
48
51
  import { fetchTransport } from "./api.js";
49
52
  import { doctorTargets, runDoctor } from "./doctor.js";
50
53
  import { setupView } from "./setup.js";
51
- function localHome(name) {
54
+ function localHome(name, config) {
52
55
  const output = home();
53
- const store = new Profiles();
54
- if (name || store.list().length) {
55
- try {
56
- const selected = store.resolve(name);
57
- output.profile = selected.name;
58
- output.tenant = selected.profile.tenantId;
59
- }
60
- catch (error) {
61
- if (name || !(error instanceof AxiError) || error.code !== "AUTH_REQUIRED")
62
- throw error;
56
+ const store = new Profiles(config);
57
+ try {
58
+ const selected = store.resolve(name);
59
+ output.profile = selected.name;
60
+ output.tenant = selected.profile.tenantId;
61
+ }
62
+ catch (error) {
63
+ if ((name?.trim() ? name : undefined) || !(error instanceof AxiError) || !["PROFILE_REQUIRED", "PROFILE_AMBIGUOUS"].includes(error.code))
64
+ throw error;
65
+ if (error.code === "PROFILE_AMBIGUOUS")
63
66
  output.profile = "unavailable: no default profile selected";
64
- }
65
67
  }
66
- output.help.unshift("npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi login --help");
68
+ output.help.unshift("npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi auth login --help");
67
69
  return output;
68
70
  }
69
71
  // One dispatch for the hook installer: resolve the sibling hook entry point
@@ -73,17 +75,34 @@ function localHome(name) {
73
75
  // summary from src/hook.ts. Install failures throw loudly; hook failures
74
76
  // never do.
75
77
  function runSetupHooks() {
76
- const hookPath = join(dirname(process.argv[1] ?? ""), "mg-axi-hook.js");
78
+ const hookPath = join(dirname(realpathSync(process.argv[1] ?? "")), "mg-axi-hook.js");
77
79
  if (!existsSync(hookPath)) {
78
80
  throw new AxiError("Cannot locate the mg-axi-hook entry point", "HOOK_INSTALL_FAILED", [
79
81
  "Reinstall @knowttl/mg-axi so dist/bin/mg-axi-hook.js sits next to the main entry point",
80
82
  "Run mg-axi setup hooks again after reinstalling",
81
83
  ]);
82
84
  }
85
+ const target = realpathSync(hookPath);
86
+ const temporaryRoots = [tmpdir(), "/tmp", "/var/tmp"].map(path => existsSync(path) ? realpathSync(path) : path);
87
+ const inTemporaryDirectory = temporaryRoots.some(root => {
88
+ const path = relative(root, target);
89
+ return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
90
+ });
91
+ let inWorktree = false;
92
+ for (let directory = dirname(target); directory !== dirname(directory); directory = dirname(directory)) {
93
+ const gitPath = join(directory, ".git");
94
+ if (existsSync(gitPath) && statSync(gitPath).isFile())
95
+ inWorktree = true;
96
+ }
97
+ if (inTemporaryDirectory || inWorktree || /(?:^|[/\\])(?:\.no-mistakes|\.treehouse|worktrees)(?:[/\\]|$)/.test(target)) {
98
+ throw new AxiError("Cannot install hooks from a temporary directory or worktree", "HOOK_INSTALL_FAILED", [
99
+ "Install @knowttl/mg-axi globally, then run mg-axi setup hooks from that installation",
100
+ ]);
101
+ }
83
102
  const failures = [];
84
103
  installSessionStartHooks({
85
104
  marker: "mg-axi-hook",
86
- execPath: hookPath,
105
+ execPath: target,
87
106
  binaryNames: ["mg-axi-hook"],
88
107
  distEntrypoints: ["dist/bin/mg-axi-hook.js"],
89
108
  onError: (message) => { failures.push(message); },
@@ -115,23 +134,30 @@ export async function executeArgv(argv, overrides = {}) {
115
134
  if (flags.help)
116
135
  return leafHelp(leaf);
117
136
  if (leaf.path === "home")
118
- return localHome(flags.profile);
119
- const profiles = new Profiles();
137
+ return localHome(flags.profile, flags.config);
138
+ const profiles = new Profiles(flags.config);
139
+ if (leaf.path === "config path")
140
+ return { config: profiles.path };
120
141
  if (leaf.path === "profile create") {
121
- const credentialFlags = ["certificate-thumbprint", "client-secret-env", "client-secret-file", "certificate-key-file", "certificate-key-env", "federated-token-file-env"];
142
+ const credentialFlags = ["certificate-thumbprint-sha256", "client-secret-env", "client-secret-file", "certificate-key-file", "certificate-key-env", "federated-token-file"];
122
143
  if (String(flags.mode ?? "delegated") !== "application" && (flags.federated || credentialFlags.some(name => flags[name] !== undefined)))
123
144
  throw new AxiError("Certificate, client-secret and federated credentials belong to application profiles; pass --mode application", "VALIDATION_ERROR", [leafHelp(leaf)]);
124
145
  const optional = (name) => flags[name] === undefined ? undefined : String(flags[name]);
125
146
  return profiles.create(String(flags.name), String(flags.tenant), String(flags.client), String(flags.cloud), !!flags["allow-device-code"], String(flags.mode ?? "delegated") === "application"
126
- ? { certificateThumbprint: optional("certificate-thumbprint"), federated: !!flags.federated, clientSecretEnv: optional("client-secret-env"), clientSecretFile: optional("client-secret-file"), certificateKeyFile: optional("certificate-key-file"), certificateKeyEnv: optional("certificate-key-env"), federatedTokenFileEnv: optional("federated-token-file-env") }
147
+ ? { certificateThumbprint: optional("certificate-thumbprint-sha256"), federated: !!flags.federated, clientSecretEnv: optional("client-secret-env"), clientSecretFile: optional("client-secret-file"), certificateKeyFile: optional("certificate-key-file"), certificateKeyEnv: optional("certificate-key-env"), federatedTokenFile: optional("federated-token-file") }
127
148
  : undefined);
128
149
  }
129
150
  if (leaf.path === "profile list") {
130
151
  const items = profiles.list();
131
- return items.length ? { profiles: items, help: ["npx -y @knowttl/mg-axi profile show --profile <name>", "npx -y @knowttl/mg-axi login --help"] } : { profiles: "0 profiles configured", help: ["npx -y @knowttl/mg-axi profile create --help"] };
152
+ return items.length ? { profiles: items, help: ["npx -y @knowttl/mg-axi profile show --profile <name>", "npx -y @knowttl/mg-axi auth login --help"] } : { profiles: "0 profiles configured", help: ["npx -y @knowttl/mg-axi profile create --help"] };
132
153
  }
133
154
  if (leaf.path === "profile show")
134
155
  return profiles.resolve(flags.profile);
156
+ if (leaf.path === "auth status") {
157
+ const selected = profiles.resolve(flags.profile);
158
+ profiles.verifyPermissions();
159
+ return authStatus(selected.name, selected.profile);
160
+ }
135
161
  if (leaf.path === "setup")
136
162
  return setupView(profiles);
137
163
  if (leaf.path === "setup hooks")
@@ -153,18 +179,20 @@ export async function executeArgv(argv, overrides = {}) {
153
179
  const session = new GraphSession({ delegated, application, transport: overrides.transport ?? fetchTransport });
154
180
  // A failing profile still reports its bounded read on stdout; the
155
181
  // nonzero exit travels through process state, never the payload.
156
- const result = await runDoctor({ store: profiles, names, session });
182
+ const selected = names.length === 1 ? profiles.resolve(flags.profile) : undefined;
183
+ const result = await runDoctor({ store: profiles, names, session, selected });
157
184
  if (result.failed)
158
185
  process.exitCode = 1;
159
186
  return result.output;
160
187
  }
161
- if (leaf.path === "login") {
188
+ if (leaf.path === "login" || leaf.path === "auth login" || leaf.path === "auth logout") {
162
189
  const selected = profiles.resolve(flags.profile);
163
190
  if (selected.profile.mode !== "delegated")
164
191
  throw new AxiError("Application profiles authenticate with client credentials; interactive login is unavailable", "VALIDATION_ERROR", ["mg-axi profile show --profile <name>", "Application tokens are acquired silently with the configured Graph .default audience"]);
165
192
  const { DelegatedAuth } = await import("./auth.js");
166
193
  const { MsalProvider } = await import("./msal-provider.js");
167
- return { profile: selected.name, ...await new DelegatedAuth(new MsalProvider()).login(selected.profile, String(flags.method ?? "browser"), String(flags.scopes).split(",")) };
194
+ const service = overrides.delegated ?? new DelegatedAuth(new MsalProvider());
195
+ return { profile: selected.name, ...await (leaf.path === "auth logout" ? service.logout(selected.profile) : service.login(selected.profile, String(flags.method ?? "browser"), String(flags.scopes).split(","))) };
168
196
  }
169
197
  if (leaf.path === "entra conditional-access policy list" || leaf.path === "entra conditional-access policy show" || leaf.path === "entra conditional-access named-location list" || leaf.path === "entra conditional-access named-location show" || leaf.path === "entra conditional-access auth-strength-policy list" || leaf.path === "entra conditional-access auth-strength-policy show" || leaf.path === "entra conditional-access combination-configuration list" || leaf.path === "entra conditional-access combination-configuration show" || leaf.path === "entra conditional-access auth-method-mode list" || leaf.path === "entra conditional-access auth-method-mode show" || leaf.path === "entra conditional-access template list" || leaf.path === "entra conditional-access template show" || leaf.path === "entra conditional-access auth-context list" || leaf.path === "entra conditional-access auth-context show" || leaf.path === "entra conditional-access deleted-policy list" || leaf.path === "entra conditional-access deleted-policy show" || leaf.path === "entra conditional-access deleted-named-location list" || leaf.path === "entra conditional-access deleted-named-location show") {
170
198
  const selected = profiles.resolve(flags.profile);
@@ -1432,12 +1460,15 @@ export async function executeArgv(argv, overrides = {}) {
1432
1460
  throw new AxiError(`Command is not executable: ${operation?.disposition ?? "unavailable"} (${operation?.owningSlice ?? "no inventory mapping"})`, "NOT_IMPLEMENTED", [leafHelp(leaf)]);
1433
1461
  }
1434
1462
  export async function main() {
1463
+ const stderrWrite = process.stderr.write.bind(process.stderr);
1464
+ process.stderr.write = ((chunk, ...args) => stderrWrite(redactSecrets(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")), ...args));
1435
1465
  await runAxiCli({
1436
1466
  description: DESCRIPTION,
1437
1467
  version: VERSION,
1438
1468
  topLevelHelp: TOP_LEVEL_HELP,
1439
1469
  // Strict resolution owns leaf help, so SDK help cannot bypass validation.
1440
1470
  argv: ["dispatch"],
1471
+ stdout: { write: (value) => process.stdout.write(redactSecrets(value)) },
1441
1472
  home: () => localHome(),
1442
1473
  commands: {
1443
1474
  dispatch: async () => {
@@ -1447,7 +1478,7 @@ export async function main() {
1447
1478
  },
1448
1479
  },
1449
1480
  formatError: error => ({
1450
- output: `${encode(error instanceof AxiError ? { error: error.message, code: error.code, help: error.suggestions } : { error: "Unable to run mg-axi", help: ["mg-axi --help"] })}\n`,
1481
+ output: `${redactSecrets(encode(error instanceof AxiError ? { error: error.message, code: error.code, help: error.suggestions } : { error: "Unable to run mg-axi", help: ["mg-axi --help"] }))}\n`,
1451
1482
  exitCode: error instanceof AxiError && error.code === "VALIDATION_ERROR" ? 2 : 1,
1452
1483
  }),
1453
1484
  });
@@ -0,0 +1,78 @@
1
+ import { rmSync } from "node:fs";
2
+ import { AxiError } from "axi-sdk-js";
3
+ import { statePath } from "./paths.js";
4
+ import { PrivateFiles } from "./private-file.js";
5
+ import { profileName } from "./profiles.js";
6
+ import { registerCache } from "./redact.js";
7
+ const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
8
+ // The only persistent credential backend. Access tokens never leave memory.
9
+ export class Credentials {
10
+ path;
11
+ files;
12
+ constructor(path = statePath("credentials.json"), files = new PrivateFiles()) {
13
+ this.path = path;
14
+ this.files = files;
15
+ }
16
+ load(verifyAcl = false) {
17
+ let text;
18
+ try {
19
+ text = this.files.read(this.path, true, verifyAcl);
20
+ }
21
+ catch (error) {
22
+ if (error.code === "ENOENT")
23
+ return { version: 1, profiles: Object.create(null) };
24
+ throw error;
25
+ }
26
+ try {
27
+ const value = JSON.parse(text);
28
+ if (!object(value) || value.version !== 1 || !object(value.profiles) || Object.keys(value).some(key => !["version", "profiles"].includes(key)))
29
+ throw new Error("Invalid store");
30
+ for (const [name, entry] of Object.entries(value.profiles)) {
31
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) || !object(entry) || typeof entry.tenantId !== "string" || typeof entry.clientId !== "string" || typeof entry.cache !== "string" ||
32
+ Object.keys(entry).some(key => !["tenantId", "clientId", "cache"].includes(key)) || !object(JSON.parse(entry.cache)))
33
+ throw new Error("Invalid cache");
34
+ }
35
+ return value;
36
+ }
37
+ catch (error) {
38
+ if (error instanceof AxiError)
39
+ throw error;
40
+ throw new AxiError(`Invalid credential store ${this.path}`, "CONFIG_INVALID", ["Restore a valid protected credentials.json before signing in again"]);
41
+ }
42
+ }
43
+ name(profile) {
44
+ const name = profileName(profile);
45
+ if (!name)
46
+ throw new AxiError("Stored credentials require a named profile", "CONFIG_INVALID");
47
+ return name;
48
+ }
49
+ read(profile, verifyAcl = false) {
50
+ const records = this.load(verifyAcl).profiles;
51
+ const name = this.name(profile);
52
+ const entry = Object.hasOwn(records, name) ? records[name] : undefined;
53
+ if (!entry)
54
+ return undefined;
55
+ if (entry.tenantId.toLowerCase() !== profile.tenantId.toLowerCase() || entry.clientId.toLowerCase() !== profile.clientId.toLowerCase())
56
+ throw new AxiError("Stored credential binding does not match the selected tenant and client", "AUTH_REQUIRED", ["mg-axi auth login --profile <name> --scopes <comma-separated-Graph-scopes>"]);
57
+ return registerCache(entry.cache);
58
+ }
59
+ write(profile, serialized) {
60
+ registerCache(serialized);
61
+ const cache = JSON.parse(serialized);
62
+ delete cache.AccessToken;
63
+ const store = this.load();
64
+ Object.defineProperty(store.profiles, this.name(profile), { value: { tenantId: profile.tenantId, clientId: profile.clientId, cache: JSON.stringify(cache) }, enumerable: true, configurable: true, writable: true });
65
+ this.files.write(this.path, `${JSON.stringify(store, null, 2)}\n`);
66
+ }
67
+ remove(profile) {
68
+ const store = this.load();
69
+ const name = this.name(profile);
70
+ if (!Object.hasOwn(store.profiles, name))
71
+ return;
72
+ delete store.profiles[name];
73
+ if (Object.keys(store.profiles).length)
74
+ this.files.write(this.path, `${JSON.stringify(store, null, 2)}\n`);
75
+ else
76
+ rmSync(this.path);
77
+ }
78
+ }
package/dist/docs.js CHANGED
@@ -138,7 +138,7 @@ export function skillDescription() {
138
138
  // profile/login hints localHome() in cli.ts prepends to HOME_HELP; the
139
139
  // rest is HOME_HELP verbatim, so skill and home view cannot drift apart.
140
140
  export function skillHomeHints() {
141
- return ["npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi login --help", ...HOME_HELP];
141
+ return ["npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi auth login --help", ...HOME_HELP];
142
142
  }
143
143
  // The full installable skill. Durable guidance only: read-only by default,
144
144
  // writes need preview plus typed confirmation, no secrets in output, the
@@ -165,13 +165,13 @@ export function skillDocument() {
165
165
  "Run commands non-interactively as `npx -y @knowttl/mg-axi ...`: no global install needed and no interactive prompts.",
166
166
  "Version pinning is the installer's choice: use `npx -y @knowttl/mg-axi@<version> ...` to select a specific release.",
167
167
  "Run `npx -y @knowttl/mg-axi doctor` first.",
168
- "See [README.md](../../README.md#release) for doctor profile selection, checks and failure behavior.",
168
+ "See [README.md](../../README.md#usage) for doctor profile selection, checks and failure behavior.",
169
169
  "",
170
170
  "## Orientation",
171
171
  "",
172
172
  "The exact current leaf registry is `src/catalogue.ts`.",
173
173
  "Its capability label is `native` (implemented by an mg-axi handler).",
174
- "Its effect is `read` for Graph read leaves and the doctor health check, `write` for the gated named writes, or `local` for home, profile, login and setup views.",
174
+ "Its effect is `read` for Graph read leaves and the doctor health check, `write` for the gated named writes, or `local` for home, profile, auth, config path, login-alias and setup views.",
175
175
  "Doctor acquires credentials silently and contacts Graph; only its help view stays offline.",
176
176
  "The list below records current executable leaves; it makes no coverage claim for other Graph operations.",
177
177
  "See `docs/coverage.md` for the per-operation disposition records.",
@@ -194,18 +194,18 @@ export function skillDocument() {
194
194
  "",
195
195
  "## Setup (explicit only)",
196
196
  "",
197
- "Follow [release guidance](../../README.md#release) for npm installation and explicit skill installation; [README.md](../../README.md#mg-axi) also documents checkout builds.",
197
+ "Follow [installation guidance](../../README.md#quick-start) for npm installation and explicit skill installation; [README.md](../../README.md#development--testing) also documents checkout builds.",
198
198
  "Create profiles explicitly and keep secrets out of argv and config files:",
199
199
  "",
200
200
  "```sh",
201
201
  "npx -y @knowttl/mg-axi setup # build steps, config path and capabilities; writes nothing",
202
202
  "npx -y @knowttl/mg-axi profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial",
203
- "npx -y @knowttl/mg-axi login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
203
+ "npx -y @knowttl/mg-axi auth login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
204
204
  "npx -y @knowttl/mg-axi doctor # one bounded read per selected profile",
205
205
  "npx -y @knowttl/mg-axi entra user list --profile soc --limit 10",
206
206
  "```",
207
207
  "",
208
- "Configuration defaults to `~/.mg-axi/config.json`; `MG_AXI_CONFIG` selects a separate file.",
208
+ "Configuration uses the XDG config directory (default `~/.config/mg-axi/config.json`), or `%APPDATA%\\mg-axi\\config.json` on Windows; `--config` overrides `MG_AXI_CONFIG`.",
209
209
  "`mg-axi setup` shows the selected path and writes nothing.",
210
210
  "Application profiles authenticate headlessly through credential references: the profile names an env var or file and the environment supplies the value at acquisition time.",
211
211
  "Container examples for client secrets, certificate keys and workload federation live in [README.md](../../README.md#mg-axi).",
@@ -227,14 +227,13 @@ export function skillDocument() {
227
227
  "## Selecting a profile",
228
228
  "",
229
229
  "Read leaves accept `--profile <name>`.",
230
- "Selection uses `--profile` or the configured default.",
231
- "Without either, read leaves report `AUTH_REQUIRED`, even if only one profile exists.",
232
- "Doctor selection is documented in [README.md](../../README.md#release).",
230
+ "Profile selection order and env-profile rules live in [README Configuration](../../README.md#configuration); an explicitly selected file profile ignores env fields.",
231
+ "Doctor selection is documented in [README.md](../../README.md#usage).",
233
232
  "Without profiles, home, setup and profile list show unconfigured state; Graph read leaves report `AUTH_REQUIRED`.",
234
233
  "",
235
234
  "## Safety",
236
235
  "",
237
- "Reads run through a session that authorizes only catalogued operations before silent credential acquisition; only explicit `login` opens a browser or prints a device-code challenge.",
236
+ "Reads run through a session that authorizes only catalogued operations before silent credential acquisition; only explicit `auth login` opens a browser or prints a device-code challenge.",
238
237
  "Writes preview before sending and need `--execute` plus a typed `--confirm` repeating the target; an already-desired value is a no-op with exit 0, never an error.",
239
238
  "Output is TOON on stdout; diagnostics use stderr.",
240
239
  "Text truncates at 500 characters (`--full` restores text, never redaction); row caps resume through opaque cursors.",
@@ -407,7 +406,7 @@ export function capabilityDocument() {
407
406
  "",
408
407
  `- implemented read leaves: ${readCount} (${readLeaves.length} named Entra reads plus reviewed raw api get and doctor health check)`,
409
408
  `- implemented write leaves: ${writeLeaves.length} (named gated mutations below)`,
410
- `- local leaves: ${localCount} (home, profile, login and setup views)`,
409
+ `- local leaves: ${localCount} (home, profile, config, auth, login alias and setup views)`,
411
410
  ...DISPOSITIONS.map(disposition => `- inventory ${disposition}: ${count(disposition)}`),
412
411
  "",
413
412
  ...auditRecord(rows),