@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.
@@ -0,0 +1,227 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, isAbsolute, win32 } from "node:path";
5
+ import { AxiError } from "axi-sdk-js";
6
+ // LiteralPath and single-quoted literals keep file names out of PowerShell syntax.
7
+ export const windowsAcl = (path, directory, action) => {
8
+ const literal = `'${path.replace(/'/g, "''")}'`;
9
+ const script = `$ErrorActionPreference='Stop'; $p=${literal};
10
+ $item=Get-Item -LiteralPath $p -Force;
11
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Reparse point' };
12
+ ${action === "inspect" ? "" : `
13
+ $user=[Security.Principal.WindowsIdentity]::GetCurrent().User;
14
+ $ids=@($user.Value,'S-1-5-18','S-1-5-32-544');
15
+ ${action === "restrict" ? `
16
+ $acl=[Security.AccessControl.${directory ? "DirectorySecurity" : "FileSecurity"}]::new();
17
+ $acl.SetOwner($user); $acl.SetAccessRuleProtection($true,$false);
18
+ foreach ($id in $ids) {
19
+ $sid=[Security.Principal.SecurityIdentifier]::new($id);
20
+ $rule=[Security.AccessControl.FileSystemAccessRule]::new($sid,'FullControl','${directory ? "ContainerInherit,ObjectInherit" : "None"}','None','Allow');
21
+ $acl.AddAccessRule($rule);
22
+ }; Set-Acl -LiteralPath $p -AclObject $acl;` : `
23
+ $acl=Get-Acl -LiteralPath $p;
24
+ if (!$acl.AreAccessRulesProtected -or $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $user.Value) { throw 'Unprotected ACL' };
25
+ $rules=@($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]));
26
+ if ($rules.Count -ne 3) { throw 'Unexpected ACL' };
27
+ foreach ($id in $ids) {
28
+ $matches=@($rules | Where-Object { $_.IdentityReference.Value -eq $id -and $_.AccessControlType -eq 'Allow' -and $_.FileSystemRights -eq 'FullControl' -and !$_.IsInherited -and $_.InheritanceFlags -eq '${directory ? "ContainerInherit, ObjectInherit" : "None"}' -and $_.PropagationFlags -eq 'None' });
29
+ if ($matches.Count -ne 1) { throw 'Unexpected grant' };
30
+ };`}`}`;
31
+ execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { stdio: "pipe", windowsHide: true });
32
+ };
33
+ // All private-file permissions live here; Windows branches are injectable on Linux.
34
+ export class PrivateFiles {
35
+ platform;
36
+ acl;
37
+ uid;
38
+ constructor(platform = process.platform, acl = windowsAcl, uid = process.getuid?.()) {
39
+ this.platform = platform;
40
+ this.acl = acl;
41
+ this.uid = uid;
42
+ }
43
+ absolute(path) {
44
+ return this.platform === "win32" ? /^[A-Za-z]:[\\/]/.test(path) && win32.isAbsolute(path) : isAbsolute(path);
45
+ }
46
+ insecure(path) {
47
+ throw new AxiError(`Insecure file ${path}`, "CONFIG_INSECURE", [this.platform === "win32" ? `Re-run the tool's write to protect ${path}` : `chmod 600 '${path.replace(/'/g, "'\\''")}'`]);
48
+ }
49
+ check(path, secret = true, verifyAcl = false) {
50
+ try {
51
+ const stat = lstatSync(path);
52
+ if (!stat.isFile() || stat.isSymbolicLink())
53
+ this.insecure(path);
54
+ if (this.platform === "win32") {
55
+ try {
56
+ this.acl(path, false, verifyAcl ? "verify" : "inspect");
57
+ }
58
+ catch (error) {
59
+ if (error instanceof AxiError)
60
+ throw error;
61
+ this.insecure(path);
62
+ }
63
+ }
64
+ else if (stat.uid !== this.uid || (stat.mode & (secret ? 0o077 : 0o022)) !== 0)
65
+ this.insecure(path);
66
+ return stat;
67
+ }
68
+ catch (error) {
69
+ const code = error.code;
70
+ if (code === "ENOENT")
71
+ throw error;
72
+ if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
73
+ return this.insecure(path);
74
+ throw error;
75
+ }
76
+ }
77
+ read(path, secret = true, verifyAcl = false) {
78
+ const checked = this.check(path, secret, verifyAcl);
79
+ let fd;
80
+ try {
81
+ fd = openSync(path, constants.O_RDONLY | (this.platform === "win32" ? 0 : constants.O_NOFOLLOW));
82
+ const opened = fstatSync(fd);
83
+ if (opened.dev !== checked.dev || opened.ino !== checked.ino)
84
+ this.insecure(path);
85
+ return readFileSync(fd, "utf8").replace(/^\uFEFF/, "");
86
+ }
87
+ catch (error) {
88
+ if (error instanceof AxiError)
89
+ throw error;
90
+ const code = error.code;
91
+ if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
92
+ this.insecure(path);
93
+ throw error;
94
+ }
95
+ finally {
96
+ if (fd !== undefined)
97
+ try {
98
+ closeSync(fd);
99
+ }
100
+ catch { /* original error preserved */ }
101
+ }
102
+ }
103
+ directory(path) {
104
+ try {
105
+ let firstCreated;
106
+ if (!existsSync(path)) {
107
+ const parent = dirname(path);
108
+ if (!existsSync(parent))
109
+ firstCreated = this.directory(parent);
110
+ mkdirSync(path, { mode: 0o700 });
111
+ firstCreated ??= path;
112
+ }
113
+ const stat = lstatSync(path);
114
+ if (!stat.isDirectory() || stat.isSymbolicLink())
115
+ this.insecure(path);
116
+ if (this.platform === "win32") {
117
+ try {
118
+ this.acl(path, true, "restrict");
119
+ }
120
+ catch (error) {
121
+ if (error instanceof AxiError)
122
+ throw error;
123
+ this.insecure(path);
124
+ }
125
+ }
126
+ else {
127
+ if (stat.uid !== this.uid)
128
+ this.insecure(path);
129
+ chmodSync(path, 0o700);
130
+ }
131
+ return firstCreated;
132
+ }
133
+ catch (error) {
134
+ if (error instanceof AxiError)
135
+ throw error;
136
+ const code = error.code;
137
+ if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
138
+ this.insecure(path);
139
+ throw error;
140
+ }
141
+ }
142
+ protect(path) {
143
+ try {
144
+ const stat = lstatSync(path);
145
+ if (!stat.isFile() || stat.isSymbolicLink())
146
+ this.insecure(path);
147
+ if (this.platform === "win32") {
148
+ try {
149
+ this.acl(path, false, "restrict");
150
+ }
151
+ catch (error) {
152
+ if (error instanceof AxiError)
153
+ throw error;
154
+ this.insecure(path);
155
+ }
156
+ }
157
+ else {
158
+ if (stat.uid !== this.uid)
159
+ this.insecure(path);
160
+ chmodSync(path, 0o600);
161
+ }
162
+ }
163
+ catch (error) {
164
+ if (error instanceof AxiError)
165
+ throw error;
166
+ const code = error.code;
167
+ if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
168
+ this.insecure(path);
169
+ throw error;
170
+ }
171
+ }
172
+ write(path, content) {
173
+ this.directory(dirname(path));
174
+ const temporary = `${path}.${randomUUID()}.tmp`;
175
+ let fd;
176
+ try {
177
+ try {
178
+ fd = openSync(temporary, "wx", this.platform === "win32" ? undefined : 0o600);
179
+ try {
180
+ if (this.platform === "win32")
181
+ this.acl(temporary, false, "restrict");
182
+ }
183
+ catch (error) {
184
+ if (error instanceof AxiError)
185
+ throw error;
186
+ this.insecure(path);
187
+ }
188
+ writeFileSync(fd, content);
189
+ fsyncSync(fd);
190
+ closeSync(fd);
191
+ fd = undefined;
192
+ }
193
+ catch (error) {
194
+ if (error instanceof AxiError)
195
+ throw error;
196
+ const code = error.code;
197
+ if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
198
+ this.insecure(path);
199
+ throw error;
200
+ }
201
+ const deadline = Date.now() + 1000;
202
+ let delay = 10;
203
+ for (;;) {
204
+ try {
205
+ renameSync(temporary, path);
206
+ break;
207
+ }
208
+ catch (error) {
209
+ if (error.code === "ELOOP")
210
+ this.insecure(path);
211
+ if (this.platform !== "win32" || !["EPERM", "EBUSY", "EACCES"].includes(error.code ?? "") || Date.now() >= deadline)
212
+ throw error;
213
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(delay, deadline - Date.now()));
214
+ delay = Math.min(delay * 2, 100);
215
+ }
216
+ }
217
+ }
218
+ finally {
219
+ if (fd !== undefined)
220
+ try {
221
+ closeSync(fd);
222
+ }
223
+ catch { /* original error preserved */ }
224
+ rmSync(temporary, { force: true });
225
+ }
226
+ }
227
+ }
package/dist/profiles.js CHANGED
@@ -1,84 +1,161 @@
1
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { randomUUID } from "node:crypto";
1
+ import { existsSync } from "node:fs";
3
2
  import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
5
4
  import { AxiError } from "axi-sdk-js";
5
+ import { configPath, toolDirectory } from "./paths.js";
6
+ import { PrivateFiles } from "./private-file.js";
7
+ import { validateSecretRef } from "./secrets.js";
8
+ const names = new WeakMap();
6
9
  const guid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
- const namePattern = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
10
+ const namePattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
8
11
  const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
9
12
  const exact = (value, keys) => Object.keys(value).every(key => keys.includes(key));
10
13
  function invalid(message) {
11
- throw new AxiError(message, "VALIDATION_ERROR", ["mg-axi profile create --help", "Correct the version-1 profile configuration; no automatic migration is performed"]);
14
+ throw new AxiError(message, "CONFIG_INVALID", ["mg-axi profile create --help", "Correct the version-1 profile configuration; no automatic migration is performed"]);
12
15
  }
13
- const thumbprint = /^[0-9a-f]{40}$/i;
14
- const sharedKeys = ["mode", "tenantId", "clientId", "cloud", "enabledPacks", "preview", "sensitiveAreas", "credentialRef", "allowDeviceCode", "writes"];
15
- const MAX_WRITE_OPERATIONS = 64;
16
- const MAX_WRITE_OPERATION_LENGTH = 256;
17
- function validateWrites(value) {
18
- if (value === undefined)
19
- return undefined;
20
- if (!object(value) || !exact(value, ["allowWrites", "operations"]) || typeof value.allowWrites !== "boolean" ||
21
- !Array.isArray(value.operations) || value.operations.length === 0 || value.operations.length > MAX_WRITE_OPERATIONS ||
22
- value.operations.some(entry => typeof entry !== "string" || !entry.length || entry.length > MAX_WRITE_OPERATION_LENGTH)) {
23
- invalid("Invalid write policy; hand-edit a writes object with boolean allowWrites and a nonempty operations array of nonempty operation names");
16
+ export function booleanEnv(name, env = process.env) {
17
+ const value = env[name]?.toLowerCase();
18
+ if (value === undefined || value === "0" || value === "false")
19
+ return false;
20
+ if (value === "1" || value === "true")
21
+ return true;
22
+ return invalid(`Invalid boolean for ${name}; use 1, true, 0 or false`);
23
+ }
24
+ // Names belong to the selected configuration, never to its serialized profile schema.
25
+ export function profileName(profile) { return names.get(profile); }
26
+ function validated(source, profile) {
27
+ const name = names.get(source);
28
+ if (name !== undefined)
29
+ names.set(profile, name);
30
+ return Object.freeze(profile);
31
+ }
32
+ function shared(value) {
33
+ if (typeof value.tenantId !== "string" || !guid.test(value.tenantId) || typeof value.clientId !== "string" || !guid.test(value.clientId))
34
+ invalid("Explicit tenant and client UUIDs are required");
35
+ if (value.cloud !== undefined && value.cloud !== "commercial")
36
+ invalid("Only commercial cloud is supported");
37
+ const packs = value.enabledPacks === undefined ? ["entra"] : value.enabledPacks;
38
+ const sensitive = value.sensitiveAreas === undefined ? [] : value.sensitiveAreas;
39
+ const preview = value.preview === undefined ? false : value.preview;
40
+ if (!Array.isArray(packs) || packs.some(pack => pack !== "entra") || new Set(packs).size !== packs.length ||
41
+ !Array.isArray(sensitive) || sensitive.length || typeof preview !== "boolean")
42
+ invalid("Invalid profile policy");
43
+ let writes;
44
+ if (value.writes !== undefined) {
45
+ const policy = value.writes;
46
+ if (!object(policy) || !exact(policy, ["allowWrites", "operations"]) || typeof policy.allowWrites !== "boolean" ||
47
+ !Array.isArray(policy.operations) || !policy.operations.length || policy.operations.length > 64 ||
48
+ policy.operations.some(entry => typeof entry !== "string" || !entry.length || entry.length > 256))
49
+ invalid("Invalid write policy; use boolean allowWrites and a nonempty operations array");
50
+ writes = Object.freeze({ allowWrites: policy.allowWrites, operations: Object.freeze([...policy.operations]) });
24
51
  }
25
- return Object.freeze({ allowWrites: value.allowWrites, operations: Object.freeze([...value.operations]) });
52
+ return { tenantId: value.tenantId, clientId: value.clientId, cloud: "commercial", enabledPacks: Object.freeze([...packs]),
53
+ preview: preview, sensitiveAreas: Object.freeze([]), ...(writes === undefined ? {} : { writes }) };
26
54
  }
27
- const sharedIdentity = (value) => value.cloud === "commercial" && typeof value.tenantId === "string" && guid.test(value.tenantId) &&
28
- typeof value.clientId === "string" && guid.test(value.clientId) && typeof value.preview === "boolean" &&
29
- Array.isArray(value.enabledPacks) && value.enabledPacks.every(pack => pack === "entra") && new Set(value.enabledPacks).size === value.enabledPacks.length &&
30
- Array.isArray(value.sensitiveAreas) && value.sensitiveAreas.length === 0;
31
- export function validateDelegatedProfile(value) {
32
- if (!object(value) || !exact(value, sharedKeys) || value.mode !== "delegated" || !sharedIdentity(value) || typeof value.allowDeviceCode !== "boolean" ||
33
- !object(value.credentialRef) || !exact(value.credentialRef, ["provider", "key"]) || value.credentialRef.provider !== "os-or-session" ||
34
- typeof value.credentialRef.key !== "string" || !guid.test(value.credentialRef.key))
35
- invalid("Invalid delegated profile; explicit tenant/client IDs and commercial cloud are required, with credentials only by reference");
36
- const profile = value;
37
- const writes = validateWrites(value.writes);
38
- return Object.freeze({ mode: "delegated", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial", preview: profile.preview,
39
- allowDeviceCode: profile.allowDeviceCode, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
40
- credentialRef: Object.freeze({ provider: "os-or-session", key: profile.credentialRef.key }), ...(writes === undefined ? {} : { writes }) });
55
+ const sharedKeys = ["mode", "tenantId", "clientId", "cloud", "enabledPacks", "preview", "sensitiveAreas", "allowDeviceCode", "writes"];
56
+ export function validateDelegatedProfile(value, files) {
57
+ if (!object(value) || value.mode !== "delegated" || !exact(value, [...sharedKeys, "token"]) ||
58
+ (value.allowDeviceCode !== undefined && typeof value.allowDeviceCode !== "boolean"))
59
+ invalid("Invalid delegated profile; token must be a SecretRef");
60
+ return validated(value, { ...shared(value), mode: "delegated", allowDeviceCode: value.allowDeviceCode === true, token: validateSecretRef(value.token, "token", true, files) });
41
61
  }
42
- export function validateApplicationProfile(value) {
43
- if (!object(value) || !exact(value, sharedKeys) || value.mode !== "application" || !sharedIdentity(value) || value.allowDeviceCode !== false ||
44
- !object(value.credentialRef))
45
- invalid("Invalid application profile; explicit tenant/client IDs and commercial cloud are required, with credentials only by reference");
46
- const profile = value;
47
- const ref = profile.credentialRef;
48
- const writes = validateWrites(value.writes);
49
- 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)) {
52
- return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
53
- preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
54
- credentialRef: Object.freeze({ provider: "certificate", key: ref.key, thumbprint: ref.thumbprint.toLowerCase() }), ...writeFields });
62
+ export function validateApplicationProfile(value, files) {
63
+ if (!object(value) || value.mode !== "application" || (value.allowDeviceCode !== undefined && value.allowDeviceCode !== false))
64
+ invalid("Invalid application profile; device code is unavailable");
65
+ const keys = value.auth === "certificate" ? ["privateKey", "thumbprintSha256"] : value.auth === "client-secret" ? ["clientSecret"] : value.auth === "federated" ? ["token"] : [];
66
+ if (!keys.length || !exact(value, [...sharedKeys, "auth", ...keys]))
67
+ invalid("Invalid application profile; auth must be certificate, client-secret or federated with only its credential fields");
68
+ const base = { ...shared(value), mode: "application", allowDeviceCode: false };
69
+ if (value.auth === "certificate") {
70
+ if (typeof value.thumbprintSha256 !== "string" || !/^[0-9a-f]{64}$/i.test(value.thumbprintSha256))
71
+ invalid("thumbprintSha256 must contain 64 hex digits");
72
+ return validated(value, { ...base, auth: "certificate", thumbprintSha256: value.thumbprintSha256.toLowerCase(), privateKey: validateSecretRef(value.privateKey, "privateKey", false, files) });
55
73
  }
56
- if (ref.provider === "federated" && exact(ref, ["provider", "key"]) && typeof ref.key === "string" && guid.test(ref.key)) {
57
- return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
58
- preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
59
- credentialRef: Object.freeze({ provider: "federated", key: ref.key }), ...writeFields });
74
+ if (value.auth === "client-secret")
75
+ return validated(value, { ...base, auth: "client-secret", clientSecret: validateSecretRef(value.clientSecret, "clientSecret", false, files) });
76
+ return validated(value, { ...base, auth: "federated", token: validateSecretRef(value.token, "token", false, files) });
77
+ }
78
+ export function validateProfile(value, files) {
79
+ return object(value) && value.mode === "application" ? validateApplicationProfile(value, files) : validateDelegatedProfile(value, files);
80
+ }
81
+ function envProfile(env) {
82
+ const fields = ["MODE", "TENANT_ID", "CLIENT_ID", "AUTH", "CLOUD", "PREVIEW", "ALLOW_DEVICE_CODE", "ENABLED_PACKS", "SENSITIVE_AREAS", "WRITES"];
83
+ if (!fields.some(field => env[`MG_AXI_${field}`] !== undefined))
84
+ return undefined;
85
+ if (!env.MG_AXI_MODE || !env.MG_AXI_TENANT_ID || !env.MG_AXI_CLIENT_ID)
86
+ invalid("Partial env profile; MG_AXI_MODE, MG_AXI_TENANT_ID and MG_AXI_CLIENT_ID are required");
87
+ const json = (name) => {
88
+ if (env[name] === undefined)
89
+ return undefined;
90
+ try {
91
+ return JSON.parse(env[name]);
92
+ }
93
+ catch {
94
+ return invalid(`Invalid JSON field ${name}`);
95
+ }
96
+ };
97
+ const base = { mode: env.MG_AXI_MODE, tenantId: env.MG_AXI_TENANT_ID, clientId: env.MG_AXI_CLIENT_ID, cloud: env.MG_AXI_CLOUD,
98
+ preview: booleanEnv("MG_AXI_PREVIEW", env), allowDeviceCode: booleanEnv("MG_AXI_ALLOW_DEVICE_CODE", env),
99
+ enabledPacks: json("MG_AXI_ENABLED_PACKS"), sensitiveAreas: json("MG_AXI_SENSITIVE_AREAS"), writes: json("MG_AXI_WRITES"),
100
+ ...(env.MG_AXI_AUTH === undefined ? {} : { auth: env.MG_AXI_AUTH }),
101
+ ...(env.MG_AXI_THUMBPRINT_SHA256 === undefined ? {} : { thumbprintSha256: env.MG_AXI_THUMBPRINT_SHA256 }),
102
+ ...(env.MG_AXI_CLIENT_SECRET === undefined ? {} : { clientSecret: { env: "MG_AXI_CLIENT_SECRET" } }),
103
+ ...(env.MG_AXI_PRIVATE_KEY === undefined ? {} : { privateKey: { env: "MG_AXI_PRIVATE_KEY" } }),
104
+ ...(env.MG_AXI_TOKEN === undefined ? {} : { token: { env: "MG_AXI_TOKEN" } }) };
105
+ if (base.mode === "delegated") {
106
+ if (!env.MG_AXI_TOKEN)
107
+ invalid("Partial env profile; MG_AXI_TOKEN must supply a delegated cache");
108
+ return validateDelegatedProfile(base);
60
109
  }
61
- invalid("Invalid application profile; credentialRef must be a certificate or federated reference, never inlined key material");
110
+ const auth = env.MG_AXI_AUTH;
111
+ const required = auth === "certificate" ? ["PRIVATE_KEY", "THUMBPRINT_SHA256"] : auth === "client-secret" ? ["CLIENT_SECRET"] : auth === "federated" ? ["TOKEN"] : [];
112
+ if (!required.length || required.some(field => !env[`MG_AXI_${field}`]))
113
+ invalid("Partial env profile; MG_AXI_AUTH and its required credential fields must be set");
114
+ return validateApplicationProfile(base);
62
115
  }
63
- export function validateProfile(value) {
64
- if (!!value && typeof value === "object" && !Array.isArray(value) && value.mode === "application")
65
- return validateApplicationProfile(value);
66
- return validateDelegatedProfile(value);
116
+ export function movedError(kind, legacy, target, guidance) {
117
+ const quote = (path) => `'${path.replace(/'/g, "'\\''")}'`;
118
+ const move = process.platform === "win32"
119
+ ? `New-Item -ItemType Directory -Force -Path '${dirname(target).replace(/'/g, "''")}'; Move-Item -LiteralPath '${legacy.replace(/'/g, "''")}' -Destination '${target.replace(/'/g, "''")}'`
120
+ : `mkdir -p ${quote(dirname(target))} && mv ${quote(legacy)} ${quote(target)}`;
121
+ throw new AxiError(`${kind} moved from ${legacy} to ${target}`, "CONFIG_MOVED", [move, guidance]);
67
122
  }
68
123
  export class Profiles {
124
+ files;
69
125
  path;
70
- constructor(path = process.env.MG_AXI_CONFIG ?? join(homedir(), ".mg-axi", "config.json")) {
71
- this.path = path;
126
+ constructor(path, files = new PrivateFiles()) {
127
+ this.files = files;
128
+ this.path = path?.trim() ? path.trim() : configPath();
129
+ }
130
+ moved() {
131
+ const legacy = join(homedir(), ".mg-axi", "config.json");
132
+ const target = resolve(this.path) === resolve(legacy) ? join(toolDirectory("config"), "config.json") : this.path;
133
+ movedError("Configuration", legacy, target, "Update credentialRef to the flattened SecretRef format described in the AXI credential standard");
72
134
  }
73
135
  load() {
74
- let value;
136
+ const legacy = join(homedir(), ".mg-axi", "config.json");
137
+ if (resolve(this.path) === resolve(legacy) && existsSync(legacy))
138
+ this.moved();
139
+ let text;
75
140
  try {
76
- value = JSON.parse(readFileSync(this.path, "utf8"));
141
+ text = this.files.read(this.path, false);
77
142
  }
78
143
  catch (error) {
79
- if (error.code === "ENOENT")
144
+ if (error instanceof AxiError)
145
+ throw error;
146
+ if (error.code === "ENOENT") {
147
+ if (existsSync(legacy))
148
+ this.moved();
80
149
  return { version: 1, profiles: {} };
81
- invalid("Unable to read profile configuration; check the file permissions and version-1 JSON schema");
150
+ }
151
+ throw error;
152
+ }
153
+ let value;
154
+ try {
155
+ value = JSON.parse(text);
156
+ }
157
+ catch {
158
+ invalid("Unable to read profile configuration; check the version-1 JSON schema");
82
159
  }
83
160
  if (!object(value) || value.version !== 1 || !exact(value, ["version", "defaultProfile", "profiles"]) || !object(value.profiles))
84
161
  invalid("Unsupported profile configuration; explicitly migrate to version 1");
@@ -86,54 +163,69 @@ export class Profiles {
86
163
  for (const [name, profile] of Object.entries(value.profiles)) {
87
164
  if (!namePattern.test(name))
88
165
  invalid("Invalid profile name");
89
- profiles[name] = validateProfile(profile);
166
+ profiles[name] = validateProfile(profile, this.files);
167
+ names.set(profiles[name], name);
90
168
  }
91
169
  if (value.defaultProfile !== undefined && (typeof value.defaultProfile !== "string" || !Object.hasOwn(profiles, value.defaultProfile)))
92
170
  invalid("Default profile does not exist");
93
- if (new Set(Object.values(profiles).map(profile => profile.credentialRef.key)).size !== Object.keys(profiles).length)
94
- invalid("Each profile must have a separate credential reference");
95
171
  return { version: 1, profiles, defaultProfile: value.defaultProfile };
96
172
  }
97
173
  list() { return Object.entries(this.load().profiles).map(([name, profile]) => ({ name, mode: profile.mode, tenant: profile.tenantId, cloud: profile.cloud })); }
98
174
  resolve(name) {
175
+ const flag = name !== undefined && name.trim() !== "" ? name.trim() : undefined;
176
+ const envName = process.env.MG_AXI_PROFILE !== undefined && process.env.MG_AXI_PROFILE.trim() !== "" ? process.env.MG_AXI_PROFILE.trim() : undefined;
177
+ const explicit = flag ?? envName;
178
+ if (explicit === undefined) {
179
+ const profile = envProfile(process.env);
180
+ if (profile) {
181
+ names.set(profile, "env");
182
+ return { name: "env", profile };
183
+ }
184
+ }
99
185
  const config = this.load();
100
- const selected = name ?? config.defaultProfile;
101
- if (!selected || !Object.hasOwn(config.profiles, selected))
102
- throw new AxiError("No configured profile selected", "AUTH_REQUIRED", ["mg-axi profile list", "mg-axi profile create --help"]);
186
+ const configured = Object.keys(config.profiles);
187
+ const selected = explicit ?? config.defaultProfile ?? (configured.length === 1 ? configured[0] : undefined);
188
+ if (!selected)
189
+ throw new AxiError("No configured profile selected", configured.length ? "PROFILE_AMBIGUOUS" : "PROFILE_REQUIRED", ["mg-axi profile list", "mg-axi profile create --help"]);
190
+ if (!Object.hasOwn(config.profiles, selected))
191
+ throw new AxiError(`Profile ${selected} does not exist`, "PROFILE_NOT_FOUND", ["mg-axi profile list"]);
103
192
  return { name: selected, profile: config.profiles[selected] };
104
193
  }
194
+ verifyPermissions() {
195
+ try {
196
+ this.files.check(this.path, false, true);
197
+ }
198
+ catch (error) {
199
+ if (error.code !== "ENOENT")
200
+ throw error;
201
+ }
202
+ }
105
203
  create(name, tenantId, clientId, cloud, allowDeviceCode, application) {
106
204
  if (!namePattern.test(name))
107
205
  invalid("Profile name must contain only letters, digits, underscores or hyphens");
108
- const certificate = application?.certificateThumbprint;
109
- const federated = application?.federated ?? false;
110
- 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");
114
- if (application && allowDeviceCode)
115
- invalid("Application profiles never use device code; it is a delegated login method");
116
- const profile = application
117
- ? 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 })
119
- : validateDelegatedProfile({ mode: "delegated", tenantId, clientId, cloud, enabledPacks: ["entra"], preview: false, sensitiveAreas: [], credentialRef: { provider: "os-or-session", key: randomUUID() }, allowDeviceCode });
206
+ const base = { tenantId, clientId, cloud, allowDeviceCode };
207
+ let profile;
208
+ if (!application)
209
+ profile = validateDelegatedProfile({ ...base, mode: "delegated", token: { stored: true } }, this.files);
210
+ else {
211
+ const { certificateThumbprint, federated, clientSecretEnv, clientSecretFile, certificateKeyFile, certificateKeyEnv, federatedTokenFile } = application;
212
+ if (clientSecretEnv !== undefined && clientSecretFile !== undefined || certificateKeyEnv !== undefined && certificateKeyFile !== undefined)
213
+ invalid("Use exactly one env or file reference for each credential");
214
+ if ([certificateThumbprint !== undefined, federated === true, clientSecretEnv !== undefined || clientSecretFile !== undefined].filter(Boolean).length !== 1)
215
+ invalid("Select exactly one application credential kind");
216
+ if ((certificateKeyEnv !== undefined || certificateKeyFile !== undefined) && certificateThumbprint === undefined || federatedTokenFile !== undefined && !federated)
217
+ invalid("Credential holder does not match the selected application auth kind");
218
+ profile = validateApplicationProfile({ ...base, mode: "application",
219
+ ...(certificateThumbprint !== undefined ? { auth: "certificate", thumbprintSha256: certificateThumbprint, privateKey: certificateKeyFile !== undefined ? { file: certificateKeyFile } : { env: certificateKeyEnv ?? "MG_AXI_PRIVATE_KEY" } }
220
+ : federated ? { auth: "federated", ...(federatedTokenFile === undefined ? {} : { token: { file: federatedTokenFile } }) }
221
+ : { auth: "client-secret", clientSecret: clientSecretFile !== undefined ? { file: clientSecretFile } : { env: clientSecretEnv } }) }, this.files);
222
+ }
120
223
  const config = this.load();
121
224
  if (Object.hasOwn(config.profiles, name))
122
225
  invalid("Profile already exists; creation never replaces identity");
123
226
  config.profiles[name] = profile;
124
227
  config.defaultProfile ??= name;
125
- mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
126
- const temporary = `${this.path}.${randomUUID()}.tmp`;
127
- try {
128
- writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
129
- renameSync(temporary, this.path);
130
- }
131
- catch {
132
- throw new AxiError("Unable to save profile configuration", "CONFIG_ERROR", ["Check configuration directory permissions"]);
133
- }
134
- finally {
135
- rmSync(temporary, { force: true });
136
- }
228
+ this.files.write(this.path, `${JSON.stringify(config, null, 2)}\n`);
137
229
  return { name, mode: profile.mode, tenant: profile.tenantId, cloud: profile.cloud };
138
230
  }
139
231
  }
package/dist/redact.js ADDED
@@ -0,0 +1,26 @@
1
+ const secrets = new Set();
2
+ export function registerSecret(value) {
3
+ if (!value)
4
+ return value;
5
+ for (const form of [value, JSON.stringify(value).slice(1, -1), encodeURIComponent(value)])
6
+ secrets.add(form);
7
+ return value;
8
+ }
9
+ export function redactSecrets(value) {
10
+ for (const secret of [...secrets].sort((a, b) => b.length - a.length))
11
+ value = value.split(secret).join("***redacted***");
12
+ return value;
13
+ }
14
+ // MSAL caches contain opaque refresh, access and ID tokens under secret fields.
15
+ export function registerCache(value) {
16
+ registerSecret(value);
17
+ const visit = (entry, key = "") => {
18
+ if (typeof entry === "string" && /^(secret|accessToken|refreshToken|idToken)$/i.test(key))
19
+ registerSecret(entry);
20
+ else if (entry && typeof entry === "object")
21
+ for (const [name, child] of Object.entries(entry))
22
+ visit(child, name);
23
+ };
24
+ visit(JSON.parse(value));
25
+ return value;
26
+ }
@@ -0,0 +1,37 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { PrivateFiles } from "./private-file.js";
3
+ import { registerSecret } from "./redact.js";
4
+ export function validateSecretRef(value, field, stored = true, files = new PrivateFiles()) {
5
+ if (value === undefined)
6
+ return Object.freeze({ env: `MG_AXI_${field.replace(/[A-Z]/g, letter => `_${letter}`).toUpperCase()}` });
7
+ if (value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 1) {
8
+ const ref = value;
9
+ if (typeof ref.env === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(ref.env))
10
+ return Object.freeze({ env: ref.env });
11
+ if (typeof ref.file === "string" && files.absolute(ref.file))
12
+ return Object.freeze({ file: ref.file });
13
+ if (stored && ref.stored === true)
14
+ return Object.freeze({ stored: true });
15
+ }
16
+ throw new AxiError(`Invalid ${field}; use a SecretRef, never an inline secret`, "CONFIG_INVALID", ["Use exactly one validated env name, absolute local file path, or stored: true for a delegated login"]);
17
+ }
18
+ export function secretSource(ref) { return "env" in ref ? `env:${ref.env}` : "file" in ref ? `file:${ref.file}` : "stored"; }
19
+ export function resolveSecret(ref, files = new PrivateFiles(), verifyAcl = false) {
20
+ let value;
21
+ if ("env" in ref)
22
+ value = process.env[ref.env];
23
+ else if ("file" in ref) {
24
+ try {
25
+ value = files.read(ref.file, true, verifyAcl).trimEnd();
26
+ }
27
+ catch (error) {
28
+ if (error instanceof AxiError)
29
+ throw error;
30
+ if (error?.code !== "ENOENT")
31
+ throw error;
32
+ }
33
+ }
34
+ if (!value?.trim())
35
+ throw new AxiError(`Credential unavailable from ${secretSource(ref)}`, "AUTH_REQUIRED", ["Supply the named credential reference; no interactive fallback was attempted"]);
36
+ return registerSecret(value);
37
+ }
package/dist/setup.js CHANGED
@@ -105,9 +105,9 @@ 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 SecretRef - an environment variable name, an absolute file path, or stored: true for delegated login - 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
- "node dist/bin/mg-axi.js login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
110
+ "node dist/bin/mg-axi.js auth login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
111
111
  "node dist/bin/mg-axi.js doctor",
112
112
  ],
113
113
  capabilities: {