@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 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
- 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`.
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
- Certificate acquisition fails if `keytar` or the referenced key is unavailable; there is no plaintext or session-only key fallback.
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 certificate/federation guidance, without user or device-code fallback.
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 protected storage" },
510
- federated: { description: "Application only: workload federation via the AZURE_FEDERATED_TOKEN_FILE assertion source" },
511
- }, 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 --help"] },
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
- if (String(flags.mode ?? "delegated") !== "application" && (flags["certificate-thumbprint"] !== undefined || flags.federated))
122
- throw new AxiError("Certificate and federated credentials belong to application profiles; pass --mode application", "VALIDATION_ERROR", [leafHelp(leaf)]);
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: flags["certificate-thumbprint"] === undefined ? undefined : String(flags["certificate-thumbprint"]), federated: !!flags.federated }
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 protected storage references, never in argv, config files or output; credential fields carry expiry metadata only.",
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
- ...(profile.credentialRef.provider === "certificate"
11
- ? { clientCertificate: { thumbprint: profile.credentialRef.thumbprint, privateKey: await this.privateKey(profile.credentialRef.key) } }
12
- : { clientAssertion: () => this.assertion() }) },
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(key) {
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
- async assertion() {
46
+ clientSecret(ref) {
32
47
  this.storage = "session-only";
33
- const file = process.env.AZURE_FEDERATED_TOKEN_FILE;
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 new Error("Missing federated token source");
72
+ throw this.missing(ref.tokenFileEnv);
36
73
  // Read on every invocation: projected federated tokens rotate on disk.
37
- const assertion = readFileSync(file, "utf8").trim();
38
- if (!assertion)
39
- throw new Error("Missing federated token source");
40
- return assertion;
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 === "federated" && exact(ref, ["provider", "key"]) && typeof ref.key === "string" && guid.test(ref.key)) {
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: "federated", key: ref.key }), ...writeFields });
65
+ credentialRef: Object.freeze({ provider: "certificate", key: ref.key, thumbprint: ref.thumbprint.toLowerCase(), ...keySource }), ...writeFields });
60
66
  }
61
- invalid("Invalid application profile; credentialRef must be a certificate or federated reference, never inlined key material");
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 protected storage");
112
- if (application && ((certificate === undefined) === !federated))
113
- invalid("Application profiles use exactly one credential: --certificate-thumbprint or --federated");
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: certificate === undefined ? { provider: "federated", key: randomUUID() } : { provider: "certificate", key: randomUUID(), thumbprint: certificate }, allowDeviceCode: false })
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; secrets stay in protected storage references, never in argv or config`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowttl/mg-axi",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Agent-facing Microsoft Graph CLI, read-only by default",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 protected storage references, never in argv, config files or output; credential fields carry expiry metadata only.
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.