@knowttl/mg-axi 0.1.3 → 0.1.4
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 +23 -4
- package/dist/app-auth.js +5 -1
- package/dist/catalogue.js +8 -3
- package/dist/cli.js +5 -3
- package/dist/docs.js +4 -1
- package/dist/msal-app-provider.js +96 -13
- package/dist/profiles.js +55 -9
- package/dist/setup.js +1 -1
- package/package.json +1 -1
- package/skills/mg-axi/SKILL.md +4 -1
package/README.md
CHANGED
|
@@ -1256,19 +1256,38 @@ mg-axi profile create --name batch --tenant <tenant-id> --client <client-id> --c
|
|
|
1256
1256
|
mg-axi profile show --profile daemon
|
|
1257
1257
|
```
|
|
1258
1258
|
|
|
1259
|
-
Omitting `--mode` selects delegated mode; certificate and federation flags require application mode.
|
|
1259
|
+
Omitting `--mode` selects delegated mode; certificate, client-secret and federation flags require application mode.
|
|
1260
1260
|
Application profiles reject `--allow-device-code`, and `login` with either login method exits 2 before authentication.
|
|
1261
1261
|
For certificates, register the matching public certificate on the app registration.
|
|
1262
|
-
|
|
1262
|
+
By default the provider expects a PEM private key in the OS credential store under service `mg-axi` and account equal to the profile's `credentialRef.key`, shown by `profile show`.
|
|
1263
1263
|
Profile creation records the thumbprint and reference; it does not import the private key, and there is no CLI key-import command.
|
|
1264
1264
|
Provision that key through protected storage tooling; never put private keys in argv or profile JSON.
|
|
1265
|
-
|
|
1265
|
+
Keychain certificate acquisition fails if `keytar` or the referenced key is unavailable; file and environment key holders below authenticate session-only instead.
|
|
1266
1266
|
For workload federation, configure the app registration's federated credential mapping for the workload and set `AZURE_FEDERATED_TOKEN_FILE` to its assertion file.
|
|
1267
1267
|
The provider reads the file on each assertion request so projected tokens can rotate; missing or empty assertions fail authentication.
|
|
1268
|
+
`--federated-token-file-env <NAME>` names a different holder variable when the workload does not use the default.
|
|
1269
|
+
|
|
1270
|
+
Headless systems (containers, CI, services) have no OS keychain, so application profiles also accept file and environment references that are read at acquisition time:
|
|
1271
|
+
|
|
1272
|
+
```sh
|
|
1273
|
+
mg-axi profile create --name svc --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --client-secret-env MG_AXI_CLIENT_SECRET
|
|
1274
|
+
mg-axi profile create --name daemon-file --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
|
|
1275
|
+
mg-axi profile create --name daemon-env --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --certificate-thumbprint <40-hex-digit-thumbprint> --certificate-key-env MG_AXI_PRIVATE_KEY
|
|
1276
|
+
MG_AXI_CLIENT_SECRET=<secret> mg-axi doctor --profile svc
|
|
1277
|
+
```
|
|
1278
|
+
|
|
1279
|
+
A profile stores only the reference (an env var name or a file path), never the value.
|
|
1280
|
+
The environment supplies the value on each acquisition, so rotation updates the env var or file contents with no profile change.
|
|
1281
|
+
Relative paths resolve against the working directory at acquisition time.
|
|
1282
|
+
Key and secret files must be regular files owned by the current user with mode 0600, never symlinks; Windows skips the ownership and mode checks.
|
|
1283
|
+
A missing, empty or exposed holder fails closed with `AUTH_REQUIRED` naming only the reference, never its value.
|
|
1284
|
+
PEM values from env vars may use escaped `\n` newlines; passphrase-protected keys are unsupported.
|
|
1285
|
+
A client secret is weaker than a certificate or federated credential: it is a bearer string with no proof of possession.
|
|
1286
|
+
Prefer certificates or federation where the platform allows them, and rotate secrets on a schedule.
|
|
1268
1287
|
Application credentials remain in process memory and are reacquired at a 60-second expiry margin.
|
|
1269
1288
|
The service requests only `https://graph.microsoft.com/.default`, representing the app registration's admin-consented Graph application permissions, with no signed-in user.
|
|
1270
1289
|
Ask an administrator to grant those permissions on the configured app registration; per-command delegated scopes cannot narrow the application token.
|
|
1271
|
-
Acquisition failures return `AUTH_REQUIRED` with consent and
|
|
1290
|
+
Acquisition failures return `AUTH_REQUIRED` with consent and credential-holder guidance, without user or device-code fallback.
|
|
1272
1291
|
|
|
1273
1292
|
Run `corepack pnpm build`, `corepack pnpm test` (test files run at most 4 at a time via `--test-concurrency=4` in the `test` script) and `corepack pnpm lint` for shell validation.
|
|
1274
1293
|
The [implementation plan](PLAN.md) remains the design authority.
|
package/dist/app-auth.js
CHANGED
|
@@ -27,7 +27,11 @@ export class ApplicationAuth {
|
|
|
27
27
|
this.cache.set(key, credential);
|
|
28
28
|
return { ...credential };
|
|
29
29
|
}
|
|
30
|
-
catch {
|
|
30
|
+
catch (error) {
|
|
31
|
+
// Provider AUTH_REQUIRED failures already name only the credential
|
|
32
|
+
// reference, never its value; everything else stays fully generic.
|
|
33
|
+
if (error instanceof AxiError && error.code === "AUTH_REQUIRED")
|
|
34
|
+
throw error;
|
|
31
35
|
throw this.failure();
|
|
32
36
|
}
|
|
33
37
|
}
|
package/dist/catalogue.js
CHANGED
|
@@ -506,9 +506,14 @@ 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
|
|
510
|
-
|
|
511
|
-
|
|
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" },
|
|
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"] },
|
|
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,
|
package/dist/cli.js
CHANGED
|
@@ -118,10 +118,12 @@ export async function executeArgv(argv, overrides = {}) {
|
|
|
118
118
|
return localHome(flags.profile);
|
|
119
119
|
const profiles = new Profiles();
|
|
120
120
|
if (leaf.path === "profile create") {
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
const credentialFlags = ["certificate-thumbprint", "client-secret-env", "client-secret-file", "certificate-key-file", "certificate-key-env", "federated-token-file-env"];
|
|
122
|
+
if (String(flags.mode ?? "delegated") !== "application" && (flags.federated || credentialFlags.some(name => flags[name] !== undefined)))
|
|
123
|
+
throw new AxiError("Certificate, client-secret and federated credentials belong to application profiles; pass --mode application", "VALIDATION_ERROR", [leafHelp(leaf)]);
|
|
124
|
+
const optional = (name) => flags[name] === undefined ? undefined : String(flags[name]);
|
|
123
125
|
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:
|
|
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") }
|
|
125
127
|
: undefined);
|
|
126
128
|
}
|
|
127
129
|
if (leaf.path === "profile list") {
|
package/dist/docs.js
CHANGED
|
@@ -207,6 +207,9 @@ export function skillDocument() {
|
|
|
207
207
|
"",
|
|
208
208
|
"Configuration defaults to `~/.mg-axi/config.json`; `MG_AXI_CONFIG` selects a separate file.",
|
|
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:",
|
|
@@ -235,7 +238,7 @@ export function skillDocument() {
|
|
|
235
238
|
"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
239
|
"Output is TOON on stdout; diagnostics use stderr.",
|
|
237
240
|
"Text truncates at 500 characters (`--full` restores text, never redaction); row caps resume through opaque cursors.",
|
|
238
|
-
"Secrets stay in
|
|
241
|
+
"Secrets stay in credential references, never in argv, config files or output; credential fields carry expiry metadata only.",
|
|
239
242
|
"Tests run offline with fixture credentials; never point tests at a real tenant.",
|
|
240
243
|
"",
|
|
241
244
|
].join("\n");
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
2
2
|
import { ConfidentialClientApplication } from "@azure/msal-node";
|
|
3
|
+
import { AxiError } from "axi-sdk-js";
|
|
3
4
|
import { graphAudience } from "./app-auth.js";
|
|
4
5
|
// Only this adapter touches application credentials; keys and assertions are referenced, never logged.
|
|
5
6
|
export class MsalApplicationProvider {
|
|
6
7
|
storage = "session-only";
|
|
7
8
|
async acquire(profile, scopes) {
|
|
9
|
+
const ref = profile.credentialRef;
|
|
8
10
|
const configuration = {
|
|
9
11
|
auth: { clientId: profile.clientId, authority: `https://login.microsoftonline.com/${profile.tenantId}`,
|
|
10
|
-
...(
|
|
11
|
-
? {
|
|
12
|
-
:
|
|
12
|
+
...(ref.provider === "client-secret"
|
|
13
|
+
? { clientSecret: this.clientSecret(ref) }
|
|
14
|
+
: ref.provider === "certificate"
|
|
15
|
+
? { clientCertificate: { thumbprint: ref.thumbprint, privateKey: await this.privateKey(ref) } }
|
|
16
|
+
: { clientAssertion: () => this.assertion(ref) }) },
|
|
13
17
|
system: { loggerOptions: { loggerCallback: () => { }, piiLoggingEnabled: false } },
|
|
14
18
|
};
|
|
15
19
|
const result = await new ConfidentialClientApplication(configuration)
|
|
@@ -20,23 +24,102 @@ export class MsalApplicationProvider {
|
|
|
20
24
|
throw new Error("Configured application authority mismatch");
|
|
21
25
|
return { token: result.accessToken, expiresAt: result.expiresOn?.getTime() ?? NaN, tenantId: profile.tenantId, clientId: profile.clientId };
|
|
22
26
|
}
|
|
23
|
-
async privateKey(
|
|
27
|
+
async privateKey(ref) {
|
|
28
|
+
if (ref.keyFile !== undefined) {
|
|
29
|
+
this.storage = "session-only";
|
|
30
|
+
return this.normalizeKey(this.readProtectedFile(ref.keyFile), ref.keyFile);
|
|
31
|
+
}
|
|
32
|
+
if (ref.keyEnv !== undefined) {
|
|
33
|
+
this.storage = "session-only";
|
|
34
|
+
const material = process.env[ref.keyEnv];
|
|
35
|
+
if (typeof material !== "string" || !material.trim())
|
|
36
|
+
throw this.missing(ref.keyEnv);
|
|
37
|
+
return this.normalizeKey(material, ref.keyEnv);
|
|
38
|
+
}
|
|
24
39
|
const store = await import("keytar").then(module => module.default).catch(() => undefined);
|
|
25
40
|
this.storage = store ? "os-protected" : "session-only";
|
|
26
|
-
const material = await store?.getPassword("mg-axi", key).catch(() => null);
|
|
41
|
+
const material = await store?.getPassword("mg-axi", ref.key).catch(() => null);
|
|
27
42
|
if (typeof material !== "string" || !material.trim())
|
|
28
43
|
throw new Error("Missing referenced certificate private key");
|
|
29
44
|
return material;
|
|
30
45
|
}
|
|
31
|
-
|
|
46
|
+
clientSecret(ref) {
|
|
32
47
|
this.storage = "session-only";
|
|
33
|
-
|
|
48
|
+
if (ref.secretEnv !== undefined) {
|
|
49
|
+
const secret = process.env[ref.secretEnv];
|
|
50
|
+
if (typeof secret !== "string" || !secret.trim())
|
|
51
|
+
throw this.missing(ref.secretEnv);
|
|
52
|
+
return secret.trim();
|
|
53
|
+
}
|
|
54
|
+
if (ref.secretFile !== undefined)
|
|
55
|
+
return this.readProtectedFile(ref.secretFile);
|
|
56
|
+
throw new Error("Missing client-secret credential holder");
|
|
57
|
+
}
|
|
58
|
+
async assertion(ref) {
|
|
59
|
+
this.storage = "session-only";
|
|
60
|
+
if (ref.tokenFileEnv === undefined) {
|
|
61
|
+
const file = process.env.AZURE_FEDERATED_TOKEN_FILE;
|
|
62
|
+
if (!file?.trim())
|
|
63
|
+
throw new Error("Missing federated token source");
|
|
64
|
+
// Read on every invocation: projected federated tokens rotate on disk.
|
|
65
|
+
const assertion = readFileSync(file, "utf8").trim();
|
|
66
|
+
if (!assertion)
|
|
67
|
+
throw new Error("Missing federated token source");
|
|
68
|
+
return assertion;
|
|
69
|
+
}
|
|
70
|
+
const file = process.env[ref.tokenFileEnv];
|
|
34
71
|
if (!file?.trim())
|
|
35
|
-
throw
|
|
72
|
+
throw this.missing(ref.tokenFileEnv);
|
|
36
73
|
// Read on every invocation: projected federated tokens rotate on disk.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
74
|
+
try {
|
|
75
|
+
const assertion = readFileSync(file, "utf8").trim();
|
|
76
|
+
if (!assertion)
|
|
77
|
+
throw this.missing(ref.tokenFileEnv);
|
|
78
|
+
return assertion;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error instanceof AxiError)
|
|
82
|
+
throw error;
|
|
83
|
+
throw this.missing(ref.tokenFileEnv);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Key and secret files hold long-lived material: regular file, owned by the
|
|
87
|
+
// current user, mode 0600, never a symlink. Relative paths resolve against
|
|
88
|
+
// the process working directory at acquire time. Windows skips the
|
|
89
|
+
// ownership and mode checks. Every failure names only the path, never content.
|
|
90
|
+
readProtectedFile(path) {
|
|
91
|
+
try {
|
|
92
|
+
const stat = lstatSync(path);
|
|
93
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
94
|
+
throw this.missing(path);
|
|
95
|
+
if (process.platform !== "win32") {
|
|
96
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid())
|
|
97
|
+
throw this.missing(path);
|
|
98
|
+
if ((stat.mode & 0o777) !== 0o600)
|
|
99
|
+
throw this.missing(path);
|
|
100
|
+
}
|
|
101
|
+
const content = readFileSync(path, "utf8").trim();
|
|
102
|
+
if (!content)
|
|
103
|
+
throw this.missing(path);
|
|
104
|
+
return content;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error instanceof AxiError)
|
|
108
|
+
throw error;
|
|
109
|
+
throw this.missing(path);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// PEM material from files and env vars; env vars may carry escaped "\n".
|
|
113
|
+
normalizeKey(material, reference) {
|
|
114
|
+
const normalized = material.includes("\n") ? material : material.replace(/\\n/g, "\n");
|
|
115
|
+
if (/ENCRYPTED PRIVATE KEY/.test(normalized) || /Proc-Type:\s*4,ENCRYPTED/.test(normalized))
|
|
116
|
+
throw new AxiError(`Application credential reference ${reference} is passphrase-protected; passphrase-protected keys are unsupported`, "AUTH_REQUIRED", ["mg-axi profile show --profile <name>", "Provision an unencrypted PEM through the referenced environment variable or file"]);
|
|
117
|
+
if (!normalized.trim())
|
|
118
|
+
throw this.missing(reference);
|
|
119
|
+
return normalized;
|
|
120
|
+
}
|
|
121
|
+
// Fail-closed holder error: names only the env var name or file path.
|
|
122
|
+
missing(reference) {
|
|
123
|
+
return new AxiError(`Application credential unavailable for the configured reference ${reference}; no user or device-code fallback was attempted`, "AUTH_REQUIRED", ["mg-axi profile show --profile <name>", "Supply the referenced credential through the named environment variable or file"]);
|
|
41
124
|
}
|
|
42
125
|
}
|
package/dist/profiles.js
CHANGED
|
@@ -11,6 +11,9 @@ function invalid(message) {
|
|
|
11
11
|
throw new AxiError(message, "VALIDATION_ERROR", ["mg-axi profile create --help", "Correct the version-1 profile configuration; no automatic migration is performed"]);
|
|
12
12
|
}
|
|
13
13
|
const thumbprint = /^[0-9a-f]{40}$/i;
|
|
14
|
+
const envName = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
15
|
+
const credentialPath = (value) => typeof value === "string" && !!value.trim();
|
|
16
|
+
const exactlyOne = (values) => values.filter(value => value !== undefined).length === 1;
|
|
14
17
|
const sharedKeys = ["mode", "tenantId", "clientId", "cloud", "enabledPacks", "preview", "sensitiveAreas", "credentialRef", "allowDeviceCode", "writes"];
|
|
15
18
|
const MAX_WRITE_OPERATIONS = 64;
|
|
16
19
|
const MAX_WRITE_OPERATION_LENGTH = 256;
|
|
@@ -47,18 +50,35 @@ export function validateApplicationProfile(value) {
|
|
|
47
50
|
const ref = profile.credentialRef;
|
|
48
51
|
const writes = validateWrites(value.writes);
|
|
49
52
|
const writeFields = writes === undefined ? {} : { writes };
|
|
50
|
-
if (ref.provider === "certificate" && exact(ref, ["provider", "key", "thumbprint"]) && typeof ref.key === "string" && guid.test(ref.key) &&
|
|
51
|
-
typeof ref.thumbprint === "string" && thumbprint.test(ref.thumbprint)) {
|
|
53
|
+
if (ref.provider === "certificate" && exact(ref, ["provider", "key", "thumbprint", "keyFile", "keyEnv"]) && typeof ref.key === "string" && guid.test(ref.key) &&
|
|
54
|
+
typeof ref.thumbprint === "string" && thumbprint.test(ref.thumbprint) && ref.keyFile === undefined && ref.keyEnv === undefined) {
|
|
52
55
|
return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
|
|
53
56
|
preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
|
|
54
57
|
credentialRef: Object.freeze({ provider: "certificate", key: ref.key, thumbprint: ref.thumbprint.toLowerCase() }), ...writeFields });
|
|
55
58
|
}
|
|
56
|
-
if (ref.provider === "
|
|
59
|
+
if (ref.provider === "certificate" && exact(ref, ["provider", "key", "thumbprint", "keyFile", "keyEnv"]) && typeof ref.key === "string" && guid.test(ref.key) &&
|
|
60
|
+
typeof ref.thumbprint === "string" && thumbprint.test(ref.thumbprint) && exactlyOne([ref.keyFile, ref.keyEnv]) &&
|
|
61
|
+
(ref.keyFile === undefined || credentialPath(ref.keyFile)) && (ref.keyEnv === undefined || (typeof ref.keyEnv === "string" && envName.test(ref.keyEnv)))) {
|
|
62
|
+
const keySource = ref.keyFile === undefined ? { keyEnv: ref.keyEnv } : { keyFile: ref.keyFile };
|
|
57
63
|
return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
|
|
58
64
|
preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
|
|
59
|
-
credentialRef: Object.freeze({ provider: "
|
|
65
|
+
credentialRef: Object.freeze({ provider: "certificate", key: ref.key, thumbprint: ref.thumbprint.toLowerCase(), ...keySource }), ...writeFields });
|
|
60
66
|
}
|
|
61
|
-
|
|
67
|
+
if (ref.provider === "client-secret" && exact(ref, ["provider", "key", "secretEnv", "secretFile"]) && typeof ref.key === "string" && guid.test(ref.key) &&
|
|
68
|
+
exactlyOne([ref.secretEnv, ref.secretFile]) &&
|
|
69
|
+
(ref.secretEnv === undefined || (typeof ref.secretEnv === "string" && envName.test(ref.secretEnv))) && (ref.secretFile === undefined || credentialPath(ref.secretFile))) {
|
|
70
|
+
const secretSource = ref.secretEnv === undefined ? { secretFile: ref.secretFile } : { secretEnv: ref.secretEnv };
|
|
71
|
+
return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
|
|
72
|
+
preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
|
|
73
|
+
credentialRef: Object.freeze({ provider: "client-secret", key: ref.key, ...secretSource }), ...writeFields });
|
|
74
|
+
}
|
|
75
|
+
if (ref.provider === "federated" && exact(ref, ["provider", "key", "tokenFileEnv"]) && typeof ref.key === "string" && guid.test(ref.key) &&
|
|
76
|
+
(ref.tokenFileEnv === undefined || (typeof ref.tokenFileEnv === "string" && envName.test(ref.tokenFileEnv)))) {
|
|
77
|
+
return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
|
|
78
|
+
preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
|
|
79
|
+
credentialRef: Object.freeze({ provider: "federated", key: ref.key, ...(ref.tokenFileEnv === undefined ? {} : { tokenFileEnv: ref.tokenFileEnv }) }), ...writeFields });
|
|
80
|
+
}
|
|
81
|
+
invalid("Invalid application profile; credentialRef must be a certificate, client-secret or federated reference, never inlined key material");
|
|
62
82
|
}
|
|
63
83
|
export function validateProfile(value) {
|
|
64
84
|
if (!!value && typeof value === "object" && !Array.isArray(value) && value.mode === "application")
|
|
@@ -107,15 +127,41 @@ export class Profiles {
|
|
|
107
127
|
invalid("Profile name must contain only letters, digits, underscores or hyphens");
|
|
108
128
|
const certificate = application?.certificateThumbprint;
|
|
109
129
|
const federated = application?.federated ?? false;
|
|
130
|
+
const secretEnv = application?.clientSecretEnv;
|
|
131
|
+
const secretFile = application?.clientSecretFile;
|
|
132
|
+
const keyFile = application?.certificateKeyFile;
|
|
133
|
+
const keyEnv = application?.certificateKeyEnv;
|
|
134
|
+
const tokenFileEnv = application?.federatedTokenFileEnv;
|
|
110
135
|
if (application && (certificate !== undefined && (typeof certificate !== "string" || !/^[0-9a-f]{40}$/i.test(certificate))))
|
|
111
|
-
invalid("Application certificate profiles name a 40-hex-digit thumbprint; the private key stays in
|
|
112
|
-
|
|
113
|
-
|
|
136
|
+
invalid("Application certificate profiles name a 40-hex-digit thumbprint; the private key stays in referenced storage");
|
|
137
|
+
for (const [label, value] of [["--client-secret-env", secretEnv], ["--certificate-key-env", keyEnv], ["--federated-token-file-env", tokenFileEnv]]) {
|
|
138
|
+
if (application && value !== undefined && (typeof value !== "string" || !envName.test(value)))
|
|
139
|
+
invalid(`Application profiles name the credential holder with ${label}; names match /^[A-Za-z_][A-Za-z0-9_]*$/`);
|
|
140
|
+
}
|
|
141
|
+
for (const [label, value] of [["--client-secret-file", secretFile], ["--certificate-key-file", keyFile]]) {
|
|
142
|
+
if (application && value !== undefined && !credentialPath(value))
|
|
143
|
+
invalid(`Application profiles name the credential holder with ${label}; paths must be nonempty`);
|
|
144
|
+
}
|
|
145
|
+
if (application && secretEnv !== undefined && secretFile !== undefined)
|
|
146
|
+
invalid("Application client-secret profiles use exactly one holder: --client-secret-env or --client-secret-file");
|
|
147
|
+
if (application && keyFile !== undefined && keyEnv !== undefined)
|
|
148
|
+
invalid("Application certificate profiles use exactly one key holder: --certificate-key-file or --certificate-key-env; omit both for the OS keychain");
|
|
149
|
+
if (application && (keyFile !== undefined || keyEnv !== undefined) && certificate === undefined)
|
|
150
|
+
invalid("Certificate key holders need --certificate-thumbprint on the same profile");
|
|
151
|
+
if (application && tokenFileEnv !== undefined && !federated)
|
|
152
|
+
invalid("Federated token-file holders need --federated on the same profile");
|
|
153
|
+
if (application && [certificate !== undefined, secretEnv !== undefined || secretFile !== undefined, federated].filter(Boolean).length !== 1)
|
|
154
|
+
invalid("Application profiles use exactly one credential: --certificate-thumbprint, --client-secret-env/--client-secret-file or --federated");
|
|
114
155
|
if (application && allowDeviceCode)
|
|
115
156
|
invalid("Application profiles never use device code; it is a delegated login method");
|
|
157
|
+
const credentialRef = certificate === undefined
|
|
158
|
+
? secretEnv === undefined && secretFile === undefined
|
|
159
|
+
? { provider: "federated", key: randomUUID(), ...(tokenFileEnv === undefined ? {} : { tokenFileEnv }) }
|
|
160
|
+
: { provider: "client-secret", key: randomUUID(), ...(secretEnv === undefined ? { secretFile: secretFile } : { secretEnv }) }
|
|
161
|
+
: { provider: "certificate", key: randomUUID(), thumbprint: certificate, ...(keyFile === undefined && keyEnv === undefined ? {} : keyFile === undefined ? { keyEnv: keyEnv } : { keyFile }) };
|
|
116
162
|
const profile = application
|
|
117
163
|
? validateApplicationProfile({ mode: "application", tenantId, clientId, cloud, enabledPacks: ["entra"], preview: false, sensitiveAreas: [],
|
|
118
|
-
credentialRef
|
|
164
|
+
credentialRef, allowDeviceCode: false })
|
|
119
165
|
: validateDelegatedProfile({ mode: "delegated", tenantId, clientId, cloud, enabledPacks: ["entra"], preview: false, sensitiveAreas: [], credentialRef: { provider: "os-or-session", key: randomUUID() }, allowDeviceCode });
|
|
120
166
|
const config = this.load();
|
|
121
167
|
if (Object.hasOwn(config.profiles, name))
|
package/dist/setup.js
CHANGED
|
@@ -105,7 +105,7 @@ export function setupView(store) {
|
|
|
105
105
|
"node dist/bin/mg-axi.js --version",
|
|
106
106
|
],
|
|
107
107
|
guidance: [
|
|
108
|
-
`Hand-edit ${store.path} or set MG_AXI_CONFIG to a separate configuration file;
|
|
108
|
+
`Hand-edit ${store.path} or set MG_AXI_CONFIG to a separate configuration file; a profile stores only a credential reference - an OS keychain entry, an environment variable name, or a file path - and never the secret value, never in argv or config`,
|
|
109
109
|
"node dist/bin/mg-axi.js profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial",
|
|
110
110
|
"node dist/bin/mg-axi.js login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
|
|
111
111
|
"node dist/bin/mg-axi.js doctor",
|
package/package.json
CHANGED
package/skills/mg-axi/SKILL.md
CHANGED
|
@@ -346,6 +346,9 @@ npx -y @knowttl/mg-axi entra user list --profile soc --limit 10
|
|
|
346
346
|
|
|
347
347
|
Configuration defaults to `~/.mg-axi/config.json`; `MG_AXI_CONFIG` selects a separate file.
|
|
348
348
|
`mg-axi setup` shows the selected path and writes nothing.
|
|
349
|
+
Application profiles authenticate headlessly through credential references: the profile names an env var or file and the environment supplies the value at acquisition time.
|
|
350
|
+
Container examples for client secrets, certificate keys and workload federation live in [README.md](../../README.md#mg-axi).
|
|
351
|
+
A client secret is weaker than a certificate or federated credential and needs scheduled rotation.
|
|
349
352
|
|
|
350
353
|
Agent discovery has two install paths, and either suffices alone.
|
|
351
354
|
For ambient context at every agent session start instead of the skill, install the CLI globally and opt into the session hook:
|
|
@@ -374,5 +377,5 @@ Reads run through a session that authorizes only catalogued operations before si
|
|
|
374
377
|
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.
|
|
375
378
|
Output is TOON on stdout; diagnostics use stderr.
|
|
376
379
|
Text truncates at 500 characters (`--full` restores text, never redaction); row caps resume through opaque cursors.
|
|
377
|
-
Secrets stay in
|
|
380
|
+
Secrets stay in credential references, never in argv, config files or output; credential fields carry expiry metadata only.
|
|
378
381
|
Tests run offline with fixture credentials; never point tests at a real tenant.
|