@knowttl/mg-axi 0.1.3 → 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.
- package/README.md +304 -1250
- package/axi-credential.schema.json +65 -0
- package/dist/app-auth.js +13 -4
- package/dist/auth-status.js +20 -0
- package/dist/auth.js +24 -10
- package/dist/catalogue.js +17 -6
- package/dist/cli.js +60 -27
- package/dist/credentials.js +78 -0
- package/dist/docs.js +14 -12
- package/dist/doctor.js +10 -8
- package/dist/graph-session.js +15 -11
- package/dist/hook.js +9 -32
- package/dist/msal-app-provider.js +32 -23
- package/dist/msal-provider.js +67 -54
- package/dist/mutations.js +42 -53
- package/dist/paths.js +17 -0
- package/dist/private-file.js +227 -0
- package/dist/profiles.js +181 -89
- package/dist/redact.js +26 -0
- package/dist/secrets.js +37 -0
- package/dist/setup.js +2 -2
- package/docs/coverage.md +1 -1
- package/package.json +4 -6
- package/skills/mg-axi/SKILL.md +18 -12
|
@@ -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);
|
|
@@ -27,7 +31,11 @@ export class ApplicationAuth {
|
|
|
27
31
|
this.cache.set(key, credential);
|
|
28
32
|
return { ...credential };
|
|
29
33
|
}
|
|
30
|
-
catch {
|
|
34
|
+
catch (error) {
|
|
35
|
+
// Provider AUTH_REQUIRED failures already name only the credential
|
|
36
|
+
// reference, never its value; everything else stays fully generic.
|
|
37
|
+
if (error instanceof AxiError && ["AUTH_REQUIRED", "CONFIG_INVALID", "CONFIG_INSECURE"].includes(error.code))
|
|
38
|
+
throw error;
|
|
31
39
|
throw this.failure();
|
|
32
40
|
}
|
|
33
41
|
}
|
|
@@ -37,10 +45,11 @@ export class ApplicationAuth {
|
|
|
37
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"]);
|
|
38
46
|
return configured;
|
|
39
47
|
}
|
|
40
|
-
key(profile, scopes) { return JSON.stringify([profile.
|
|
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]); }
|
|
41
49
|
check(profile, credential) {
|
|
42
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)
|
|
43
51
|
throw new Error("Invalid credential context");
|
|
52
|
+
registerSecret(credential.token);
|
|
44
53
|
return { ...credential };
|
|
45
54
|
}
|
|
46
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 {
|
|
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.
|
|
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
|
|
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,18 +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: "
|
|
510
|
-
|
|
511
|
-
|
|
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
|
+
"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: "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"] },
|
|
512
517
|
{ path: "profile list", description: "List configured profiles without accessing credentials", flags: {}, examples: ["mg-axi profile list", "mg-axi profile list --help"] },
|
|
513
518
|
{ path: "profile show", description: "Show profile policy and credential reference, never credentials", flags: {
|
|
514
519
|
profile: common.profile,
|
|
515
520
|
}, examples: ["mg-axi profile show --profile soc", "mg-axi profile show"] },
|
|
516
|
-
{ 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: {
|
|
517
522
|
profile: common.profile,
|
|
518
523
|
method: { value: "browser|device-code", default: "browser", description: "Device code additionally requires profile opt-in" },
|
|
519
524
|
scopes: { value: "comma-separated-Graph-scopes", required: true, description: "Explicit delegated permissions using full https://graph.microsoft.com/ scope names" },
|
|
520
|
-
}, 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"] },
|
|
521
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"] },
|
|
522
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"] },
|
|
523
528
|
{ path: "doctor", description: "Check each selected profile with one bounded user-list read; never signs in interactively, installs nothing, enables no writes", flags: {
|
|
@@ -2785,6 +2790,12 @@ export const LEAVES = [
|
|
|
2785
2790
|
full: { description: "Disable 500-character string truncation; never disables redaction or row caps" },
|
|
2786
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"] },
|
|
2787
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" };
|
|
2788
2799
|
export function leafHelp(leaf) {
|
|
2789
2800
|
return [
|
|
2790
2801
|
`mg-axi ${leaf.path}${leaf.positional ? ` <${leaf.positional.name}>` : ""}`, leaf.description,
|
|
@@ -2823,7 +2834,7 @@ export function resolveCommand(argv) {
|
|
|
2823
2834
|
const flags = Object.create(null);
|
|
2824
2835
|
for (let i = flagStart; i < argv.length; i++) {
|
|
2825
2836
|
const arg = argv[i];
|
|
2826
|
-
const match = /^--([a-z-]
|
|
2837
|
+
const match = /^--([a-z][a-z0-9-]*)(?:=(.*))?$/.exec(arg);
|
|
2827
2838
|
if (!match)
|
|
2828
2839
|
fail("unexpected argument or short flag");
|
|
2829
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 {
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
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,21 +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
|
-
|
|
122
|
-
|
|
142
|
+
const credentialFlags = ["certificate-thumbprint-sha256", "client-secret-env", "client-secret-file", "certificate-key-file", "certificate-key-env", "federated-token-file"];
|
|
143
|
+
if (String(flags.mode ?? "delegated") !== "application" && (flags.federated || credentialFlags.some(name => flags[name] !== undefined)))
|
|
144
|
+
throw new AxiError("Certificate, client-secret and federated credentials belong to application profiles; pass --mode application", "VALIDATION_ERROR", [leafHelp(leaf)]);
|
|
145
|
+
const optional = (name) => flags[name] === undefined ? undefined : String(flags[name]);
|
|
123
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"
|
|
124
|
-
? { certificateThumbprint:
|
|
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") }
|
|
125
148
|
: undefined);
|
|
126
149
|
}
|
|
127
150
|
if (leaf.path === "profile list") {
|
|
128
151
|
const items = profiles.list();
|
|
129
|
-
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"] };
|
|
130
153
|
}
|
|
131
154
|
if (leaf.path === "profile show")
|
|
132
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
|
+
}
|
|
133
161
|
if (leaf.path === "setup")
|
|
134
162
|
return setupView(profiles);
|
|
135
163
|
if (leaf.path === "setup hooks")
|
|
@@ -151,18 +179,20 @@ export async function executeArgv(argv, overrides = {}) {
|
|
|
151
179
|
const session = new GraphSession({ delegated, application, transport: overrides.transport ?? fetchTransport });
|
|
152
180
|
// A failing profile still reports its bounded read on stdout; the
|
|
153
181
|
// nonzero exit travels through process state, never the payload.
|
|
154
|
-
const
|
|
182
|
+
const selected = names.length === 1 ? profiles.resolve(flags.profile) : undefined;
|
|
183
|
+
const result = await runDoctor({ store: profiles, names, session, selected });
|
|
155
184
|
if (result.failed)
|
|
156
185
|
process.exitCode = 1;
|
|
157
186
|
return result.output;
|
|
158
187
|
}
|
|
159
|
-
if (leaf.path === "login") {
|
|
188
|
+
if (leaf.path === "login" || leaf.path === "auth login" || leaf.path === "auth logout") {
|
|
160
189
|
const selected = profiles.resolve(flags.profile);
|
|
161
190
|
if (selected.profile.mode !== "delegated")
|
|
162
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"]);
|
|
163
192
|
const { DelegatedAuth } = await import("./auth.js");
|
|
164
193
|
const { MsalProvider } = await import("./msal-provider.js");
|
|
165
|
-
|
|
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(","))) };
|
|
166
196
|
}
|
|
167
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") {
|
|
168
198
|
const selected = profiles.resolve(flags.profile);
|
|
@@ -1430,12 +1460,15 @@ export async function executeArgv(argv, overrides = {}) {
|
|
|
1430
1460
|
throw new AxiError(`Command is not executable: ${operation?.disposition ?? "unavailable"} (${operation?.owningSlice ?? "no inventory mapping"})`, "NOT_IMPLEMENTED", [leafHelp(leaf)]);
|
|
1431
1461
|
}
|
|
1432
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));
|
|
1433
1465
|
await runAxiCli({
|
|
1434
1466
|
description: DESCRIPTION,
|
|
1435
1467
|
version: VERSION,
|
|
1436
1468
|
topLevelHelp: TOP_LEVEL_HELP,
|
|
1437
1469
|
// Strict resolution owns leaf help, so SDK help cannot bypass validation.
|
|
1438
1470
|
argv: ["dispatch"],
|
|
1471
|
+
stdout: { write: (value) => process.stdout.write(redactSecrets(value)) },
|
|
1439
1472
|
home: () => localHome(),
|
|
1440
1473
|
commands: {
|
|
1441
1474
|
dispatch: async () => {
|
|
@@ -1445,7 +1478,7 @@ export async function main() {
|
|
|
1445
1478
|
},
|
|
1446
1479
|
},
|
|
1447
1480
|
formatError: error => ({
|
|
1448
|
-
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`,
|
|
1449
1482
|
exitCode: error instanceof AxiError && error.code === "VALIDATION_ERROR" ? 2 : 1,
|
|
1450
1483
|
}),
|
|
1451
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#
|
|
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,19 +194,22 @@ export function skillDocument() {
|
|
|
194
194
|
"",
|
|
195
195
|
"## Setup (explicit only)",
|
|
196
196
|
"",
|
|
197
|
-
"Follow [
|
|
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
|
|
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
|
+
"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
|
+
"Container examples for client secrets, certificate keys and workload federation live in [README.md](../../README.md#mg-axi).",
|
|
212
|
+
"A client secret is weaker than a certificate or federated credential and needs scheduled rotation.",
|
|
210
213
|
"",
|
|
211
214
|
"Agent discovery has two install paths, and either suffices alone.",
|
|
212
215
|
"For ambient context at every agent session start instead of the skill, install the CLI globally and opt into the session hook:",
|
|
@@ -224,18 +227,17 @@ export function skillDocument() {
|
|
|
224
227
|
"## Selecting a profile",
|
|
225
228
|
"",
|
|
226
229
|
"Read leaves accept `--profile <name>`.",
|
|
227
|
-
"
|
|
228
|
-
"
|
|
229
|
-
"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).",
|
|
230
232
|
"Without profiles, home, setup and profile list show unconfigured state; Graph read leaves report `AUTH_REQUIRED`.",
|
|
231
233
|
"",
|
|
232
234
|
"## Safety",
|
|
233
235
|
"",
|
|
234
|
-
"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.",
|
|
235
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.",
|
|
236
238
|
"Output is TOON on stdout; diagnostics use stderr.",
|
|
237
239
|
"Text truncates at 500 characters (`--full` restores text, never redaction); row caps resume through opaque cursors.",
|
|
238
|
-
"Secrets stay in
|
|
240
|
+
"Secrets stay in credential references, never in argv, config files or output; credential fields carry expiry metadata only.",
|
|
239
241
|
"Tests run offline with fixture credentials; never point tests at a real tenant.",
|
|
240
242
|
"",
|
|
241
243
|
].join("\n");
|
|
@@ -404,7 +406,7 @@ export function capabilityDocument() {
|
|
|
404
406
|
"",
|
|
405
407
|
`- implemented read leaves: ${readCount} (${readLeaves.length} named Entra reads plus reviewed raw api get and doctor health check)`,
|
|
406
408
|
`- implemented write leaves: ${writeLeaves.length} (named gated mutations below)`,
|
|
407
|
-
`- local leaves: ${localCount} (home, profile, login and setup views)`,
|
|
409
|
+
`- local leaves: ${localCount} (home, profile, config, auth, login alias and setup views)`,
|
|
408
410
|
...DISPOSITIONS.map(disposition => `- inventory ${disposition}: ${count(disposition)}`),
|
|
409
411
|
"",
|
|
410
412
|
...auditRecord(rows),
|