@cdot65/prisma-airs-cli 5.5.0 → 5.7.0

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
@@ -25,7 +25,7 @@
25
25
  - **Unified automation output** — resource reads support `pretty`, `table`, `markdown`, `csv`, `json`, and `yaml`, with pipe-safe stdout; environment deliverables use HTML or Markdown
26
26
  - **Complete pagination** — consistent `--limit`, `--offset`, and `--all` traversal with a configurable safety cap
27
27
  - **`airs doctor`** — one-command diagnostics for environment, credentials, and API connectivity
28
- - **Tenant selection** — `airs tenant create|switch|list|read|delete` selects existing config files without copying secrets or changing read-only mounts
28
+ - **Tenant configuration** — `airs tenant create|set|switch|list|read|delete` supports guided setup with hidden secret prompts, individual setting updates, and existing-file registration without changing read-only mounts
29
29
  - **Profile migration** — `airs runtime profiles backup|restore` exports private JSON/YAML, previews cross-tenant restores, remaps topics and explicit DLP dependencies, and verifies restored policies ([guide](https://cdot65.github.io/prisma-airs-cli/runtime/profile-transfer/))
30
30
  - **`airs config`** — manage the selected config file from the CLI (`list`, `get`, `set`, `unset`, `path`)
31
31
 
@@ -2402,11 +2402,14 @@ async function changeStore(change) {
2402
2402
  await unlink(lockPath);
2403
2403
  }
2404
2404
  }
2405
- async function createTenant(name, configPath) {
2405
+ function validateTenantName(name) {
2406
2406
  if (!NameSchema.safeParse(name).success)
2407
2407
  throw new Error(
2408
2408
  "Tenant name must be 1\u201364 letters, digits, hyphens or underscores, start with a letter/digit, and not be default"
2409
2409
  );
2410
+ }
2411
+ async function createTenant(name, configPath) {
2412
+ validateTenantName(name);
2410
2413
  let path2;
2411
2414
  try {
2412
2415
  path2 = realpathSync(resolve(expandConfigPath(configPath)));
@@ -4186,10 +4189,13 @@ export {
4186
4189
  readBackupFile,
4187
4190
  readBackupDir,
4188
4191
  ConfigSchema,
4192
+ tenantStorePath,
4189
4193
  expandConfigPath,
4190
4194
  defaultTenantConfigPath,
4191
4195
  readTenantStore,
4192
4196
  readTenantConfig,
4197
+ readTenantConfigFile,
4198
+ validateTenantName,
4193
4199
  createTenant,
4194
4200
  switchTenant,
4195
4201
  deleteTenant,
package/dist/cli/index.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  readBackupDir,
24
24
  readBackupFile,
25
25
  readTenantConfig,
26
+ readTenantConfigFile,
26
27
  readTenantStore,
27
28
  renderEnvironmentReportHtml,
28
29
  renderEnvironmentReportMarkdown,
@@ -34,10 +35,12 @@ import {
34
35
  resolveOutputDir,
35
36
  sanitizeFilename,
36
37
  switchTenant,
38
+ tenantStorePath,
39
+ validateTenantName,
37
40
  validateTopic,
38
41
  writeBackupFile,
39
42
  writeReportFile
40
- } from "../chunk-NKAVUBKI.js";
43
+ } from "../chunk-XQOICLSH.js";
41
44
 
42
45
  // src/cli/index.ts
43
46
  import "dotenv/config";
@@ -56,9 +59,9 @@ function installProcessGuards() {
56
59
  }
57
60
 
58
61
  // src/cli/program.ts
59
- import { randomUUID as randomUUID10 } from "crypto";
62
+ import { randomUUID as randomUUID11 } from "crypto";
60
63
  import { readFileSync as readFileSync4 } from "fs";
61
- import { dirname as dirname4, join as join4 } from "path";
64
+ import { dirname as dirname5, join as join5 } from "path";
62
65
  import { fileURLToPath } from "url";
63
66
  import { Command } from "commander";
64
67
 
@@ -519,6 +522,10 @@ ${INDENT}${chalk3.bold(label)}
519
522
  warn(msg) {
520
523
  console.log(`${INDENT}${COLORS.warn(`${GLYPHS.warn} ${msg}`)}`);
521
524
  },
525
+ /** Safety warnings remain visible in quiet mode without corrupting structured stdout. */
526
+ warning(msg) {
527
+ console.error(`${INDENT}${COLORS.warn(`${GLYPHS.warn} ${msg}`)}`);
528
+ },
522
529
  /** Neutral informational callout. Quiet-suppressed. */
523
530
  info(msg) {
524
531
  if (quietMode) return;
@@ -3684,19 +3691,19 @@ function installDebugLogger(logPath, options = {}) {
3684
3691
  mkdirSync(dirname(logPath), { recursive: true, mode: 448 });
3685
3692
  writeFileSync(logPath, "", { encoding: "utf8", mode: 384, flag: "wx" });
3686
3693
  const originalFetch = globalThis.fetch;
3687
- globalThis.fetch = async function debugFetch(input, init2) {
3688
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
3694
+ globalThis.fetch = async function debugFetch(input2, init2) {
3695
+ const url = typeof input2 === "string" ? input2 : input2 instanceof URL ? input2.toString() : input2.url;
3689
3696
  const rawHeaders = headersToRecord(
3690
- init2?.headers ?? (input instanceof Request ? input.headers : void 0)
3697
+ init2?.headers ?? (input2 instanceof Request ? input2.headers : void 0)
3691
3698
  );
3692
3699
  const inference = Object.keys(rawHeaders).some(
3693
3700
  (name) => name.toLowerCase() === "x-portkey-api-key"
3694
3701
  );
3695
3702
  if (!isAirsUrl(url) && !inference) {
3696
- return originalFetch(input, init2);
3703
+ return originalFetch(input2, init2);
3697
3704
  }
3698
3705
  const dashboard = options.omitBodies || /\/v1\/mgmt\/(dashboard\/|reports\/scancontent(?:\?|$))/.test(url) || /\/aiag\//.test(new URL(url).pathname);
3699
- const method = init2?.method ?? (input instanceof Request ? input.method : "GET");
3706
+ const method = init2?.method ?? (input2 instanceof Request ? input2.method : "GET");
3700
3707
  const reqHeaders = redactHeaders(rawHeaders);
3701
3708
  const loggedUrl = redactUrl(url);
3702
3709
  let reqBody;
@@ -3715,7 +3722,7 @@ function installDebugLogger(logPath, options = {}) {
3715
3722
  let resBody;
3716
3723
  let error;
3717
3724
  try {
3718
- response = await originalFetch(input, init2);
3725
+ response = await originalFetch(input2, init2);
3719
3726
  } catch (err) {
3720
3727
  error = inference ? "Runtime request failed" : "Request failed";
3721
3728
  const entry2 = JSON.stringify({
@@ -6955,8 +6962,8 @@ function buildDefaultCategories(categories) {
6955
6962
  ])
6956
6963
  );
6957
6964
  }
6958
- function parseAttackGoals(input) {
6959
- const trimmed = input.trim();
6965
+ function parseAttackGoals(input2) {
6966
+ const trimmed = input2.trim();
6960
6967
  const raw = trimmed.startsWith("[") ? trimmed : fs2.readFileSync(trimmed, "utf-8");
6961
6968
  let parsed;
6962
6969
  try {
@@ -6978,10 +6985,10 @@ function sliceClientSide(items, opts) {
6978
6985
  const limit = opts.limit !== void 0 ? Number.parseInt(String(opts.limit), 10) : void 0;
6979
6986
  return items.slice(offset, limit === void 0 ? void 0 : offset + limit);
6980
6987
  }
6981
- function parsePositiveInt(input, flag) {
6982
- const n = Number.parseInt(input, 10);
6988
+ function parsePositiveInt(input2, flag) {
6989
+ const n = Number.parseInt(input2, 10);
6983
6990
  if (!Number.isFinite(n) || n <= 0) {
6984
- throw new Error(`${flag}: expected a positive integer, got "${input}"`);
6991
+ throw new Error(`${flag}: expected a positive integer, got "${input2}"`);
6985
6992
  }
6986
6993
  return n;
6987
6994
  }
@@ -7067,10 +7074,10 @@ function resolveScriptB64(opts) {
7067
7074
  }
7068
7075
  throw new Error("one of --script-file or --script-b64 is required");
7069
7076
  }
7070
- function parseAdapterVariables(input) {
7077
+ function parseAdapterVariables(input2) {
7071
7078
  let parsed;
7072
7079
  try {
7073
- parsed = JSON.parse(input);
7080
+ parsed = JSON.parse(input2);
7074
7081
  } catch (err) {
7075
7082
  throw new Error(`--variables: invalid JSON (${err instanceof Error ? err.message : err})`);
7076
7083
  }
@@ -9548,6 +9555,102 @@ import {
9548
9555
  SecurityProfileSchema
9549
9556
  } from "@cdot65/prisma-airs-sdk";
9550
9557
  import { z as z5 } from "zod";
9558
+
9559
+ // src/backup/runtime-policy.ts
9560
+ function object(value) {
9561
+ return value !== null && typeof value === "object" && !Array.isArray(value);
9562
+ }
9563
+ function compareRuntimePolicies(expected, actual) {
9564
+ const serverDefaults = [];
9565
+ const differences = [];
9566
+ const visit = (source, target, path3, insideToxicCategories = false, insideTopicGuardrails = false) => {
9567
+ if (source === target) return;
9568
+ if (Array.isArray(source) && Array.isArray(target)) {
9569
+ if (source.length !== target.length) differences.push(`${path3}.length`);
9570
+ source.forEach((item, index) => {
9571
+ visit(
9572
+ item,
9573
+ target[index],
9574
+ `${path3}[${index}]`,
9575
+ insideToxicCategories,
9576
+ insideTopicGuardrails
9577
+ );
9578
+ });
9579
+ return;
9580
+ }
9581
+ if (object(source) && object(target)) {
9582
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(source), ...Object.keys(target)])) {
9583
+ const field = `${path3}.${key}`;
9584
+ if (!Object.hasOwn(source, key) && Object.hasOwn(target, key)) {
9585
+ const model = /^policy\.ai-security-profiles\[\d+\]\.model-configuration/;
9586
+ const relative = path3.replace(model, "model");
9587
+ let allowed;
9588
+ if (relative === "model.data-protection" && key === "database-security") allowed = null;
9589
+ if (key === "severity") {
9590
+ if (/^model\.data-protection\.database-security\[\d+\]$/.test(relative)) {
9591
+ const values = {
9592
+ "database-security-create": "medium",
9593
+ "database-security-read": "low",
9594
+ "database-security-update": "medium",
9595
+ "database-security-delete": "high"
9596
+ };
9597
+ if (source.name === target.name && typeof source.name === "string")
9598
+ allowed = Object.hasOwn(values, source.name) ? values[source.name] : void 0;
9599
+ }
9600
+ if (relative === "model.data-protection.source-code-detection") allowed = "high";
9601
+ if (relative === "model.app-protection.malicious-code-protection" && source.name === "malicious-code" && target.name === source.name)
9602
+ allowed = "high";
9603
+ if (/^model\.model-protection\[\d+\]$/.test(relative) && (source.name === "prompt-injection" || source.name === "topic-guardrails" || source.name === "contextual-grounding" && source.action === "block") && target.name === source.name)
9604
+ allowed = "medium";
9605
+ if (insideTopicGuardrails && /^model\.model-protection\[\d+\]\.topic-list\[\d+\]\.topic\[\d+\]$/.test(relative) && typeof source.topic_id === "string" && source.topic_id.length > 0 && source.topic_id === target.topic_id && source.topic_name === target.topic_name && Number.isInteger(source.revision) && source.revision === target.revision)
9606
+ allowed = "medium";
9607
+ if (/^model\.agent-protection\[\d+\]$/.test(relative) && source.name === "agent-security" && target.name === source.name)
9608
+ allowed = "medium";
9609
+ }
9610
+ if (relative === "model.app-protection" && key === "url-detected-severity")
9611
+ allowed = "low";
9612
+ if (relative === "model.app-protection" && key === "default-url-category" && object(target[key])) {
9613
+ const value = target[key];
9614
+ if (Object.keys(value).length === 1 && value.member === null) {
9615
+ serverDefaults.push(field);
9616
+ continue;
9617
+ }
9618
+ }
9619
+ if ((/^model\.model-protection\[\d+\]$/.test(relative) && source.name === "toxic-content" && target.name === source.name || insideToxicCategories && /^model\.model-protection\[\d+\]\.toxic-category-list\[\d+\]$/.test(relative) && typeof source.category === "string" && source.category.length > 0 && source.category === target.category) && key === "severity-by-confidence" && object(target[key])) {
9620
+ const value = target[key];
9621
+ if (Object.keys(value).length === 2 && value.high === "medium" && value.moderate === "low") {
9622
+ serverDefaults.push(field);
9623
+ continue;
9624
+ }
9625
+ }
9626
+ if (allowed !== void 0 && target[key] === allowed) {
9627
+ serverDefaults.push(field);
9628
+ continue;
9629
+ }
9630
+ differences.push(field);
9631
+ } else if (!Object.hasOwn(target, key)) differences.push(field);
9632
+ else
9633
+ visit(
9634
+ source[key],
9635
+ target[key],
9636
+ field,
9637
+ key === "toxic-category-list" && source.name === "toxic-content" && target.name === source.name && /^policy\.ai-security-profiles\[\d+\]\.model-configuration\.model-protection\[\d+\]$/.test(
9638
+ path3
9639
+ ),
9640
+ key === "topic-list" && source.name === "topic-guardrails" && target.name === source.name && /^policy\.ai-security-profiles\[\d+\]\.model-configuration\.model-protection\[\d+\]$/.test(
9641
+ path3
9642
+ ) || insideTopicGuardrails && key === "topic" && source.action === "block" && target.action === "block"
9643
+ );
9644
+ }
9645
+ return;
9646
+ }
9647
+ differences.push(path3);
9648
+ };
9649
+ visit(expected, actual, "policy");
9650
+ return { matches: differences.length === 0, serverDefaults, differences };
9651
+ }
9652
+
9653
+ // src/backup/runtime-profiles.ts
9551
9654
  var RuntimeProfilesBackupSchema = z5.object({
9552
9655
  version: z5.literal(1),
9553
9656
  resourceType: z5.literal("runtime-security-profiles"),
@@ -9556,7 +9659,7 @@ var RuntimeProfilesBackupSchema = z5.object({
9556
9659
  profiles: z5.array(SecurityProfileSchema).min(1).max(1e4),
9557
9660
  topics: z5.array(CustomTopicSchema).max(1e4)
9558
9661
  }).strict();
9559
- function object(value) {
9662
+ function object2(value) {
9560
9663
  return value !== null && typeof value === "object" && !Array.isArray(value);
9561
9664
  }
9562
9665
  function pagesLimit(value) {
@@ -9623,7 +9726,7 @@ function topicReferences(policy) {
9623
9726
  value.forEach(visit);
9624
9727
  return;
9625
9728
  }
9626
- if (!object(value)) return;
9729
+ if (!object2(value)) return;
9627
9730
  if ("topic_id" in value) {
9628
9731
  if (typeof value.topic_id !== "string" || typeof value.topic_name !== "string" || !Number.isSafeInteger(value.revision) || Number(value.revision) < 0)
9629
9732
  throw new Error("Invalid topic identity in profile policy");
@@ -9676,26 +9779,48 @@ function sameTopic(a, b) {
9676
9779
  return a.description === b.description && JSON.stringify(a.examples) === JSON.stringify(b.examples) && (a.active ?? true) === (b.active ?? true);
9677
9780
  }
9678
9781
  function dlpReferences(policy) {
9679
- if (!object(policy)) return [];
9782
+ if (!object2(policy)) return [];
9680
9783
  const refs = [];
9681
9784
  for (const entry of Array.isArray(policy["dlp-data-profiles"]) ? policy["dlp-data-profiles"] : []) {
9682
- if (!object(entry) || typeof entry.name !== "string")
9785
+ if (!object2(entry) || typeof entry.name !== "string")
9683
9786
  throw new Error("Invalid embedded DLP reference");
9684
9787
  refs.push({ value: entry, name: entry.name, kind: "embedded" });
9685
9788
  }
9686
9789
  for (const entry of Array.isArray(policy["ai-security-profiles"]) ? policy["ai-security-profiles"] : []) {
9687
- if (!object(entry) || !object(entry["model-configuration"])) continue;
9790
+ if (!object2(entry) || !object2(entry["model-configuration"])) continue;
9688
9791
  const protection = entry["model-configuration"]["data-protection"];
9689
- if (!object(protection) || !object(protection["data-leak-detection"])) continue;
9792
+ if (!object2(protection) || !object2(protection["data-leak-detection"])) continue;
9690
9793
  const members = protection["data-leak-detection"].member;
9691
9794
  if (!Array.isArray(members)) continue;
9692
9795
  for (const member of members) {
9693
- if (!object(member) || typeof member.text !== "string") throw new Error("Invalid DLP member");
9796
+ if (!object2(member) || typeof member.text !== "string") throw new Error("Invalid DLP member");
9797
+ if (member.text.toLowerCase() === "sensitive content" && (member.id === "" || member.id === void 0) && (member.version === "2" || member.version === void 0) && !refs.some((ref) => ref.kind === "embedded" && ref.name === member.text))
9798
+ continue;
9694
9799
  refs.push({ value: member, name: member.text, kind: "member" });
9695
9800
  }
9696
9801
  }
9697
9802
  return refs;
9698
9803
  }
9804
+ function basicFallback(policy) {
9805
+ const result = structuredClone(policy);
9806
+ const customNames = new Set(dlpReferences(result).map((ref) => ref.name));
9807
+ let converted = false;
9808
+ for (const entry of result?.["ai-security-profiles"] ?? []) {
9809
+ const detection = entry["model-configuration"]?.["data-protection"]?.["data-leak-detection"];
9810
+ if (!detection?.member?.some((member) => customNames.has(member.text))) continue;
9811
+ if (!["block", "allow", ""].includes(detection.action))
9812
+ throw new Error("Cannot safely fall back to Basic: unsupported DLP action");
9813
+ if (detection["mask-data-inline"] && detection.action !== "block")
9814
+ throw new Error("Cannot safely fall back to Basic: inline masking requires a block action");
9815
+ detection.member = detection.action === "" ? null : [{ text: "sensitive content", id: "", version: "2" }];
9816
+ converted = true;
9817
+ }
9818
+ if (!converted)
9819
+ throw new Error("Cannot safely fall back to Basic: no matching DLP detection configuration");
9820
+ if (result && "dlp-data-profiles" in result) result["dlp-data-profiles"] = [];
9821
+ PolicySchema.parse(result);
9822
+ return result;
9823
+ }
9699
9824
  function assertKnownPolicyReferences(policy) {
9700
9825
  const known = new Map([
9701
9826
  ...topicReferences(policy).map((ref) => [ref, ["topic_id"]]),
@@ -9711,7 +9836,7 @@ function assertKnownPolicyReferences(policy) {
9711
9836
  });
9712
9837
  return;
9713
9838
  }
9714
- if (!object(value)) return;
9839
+ if (!object2(value)) return;
9715
9840
  for (const [key, child] of Object.entries(value)) {
9716
9841
  if (child !== null && child !== "" && /(?:^|[-_])(id|uuid)$|(?:Id|ID|Uuid|UUID)$/.test(key) && !known.get(value)?.includes(key))
9717
9842
  throw new Error(`Unsupported cross-tenant policy reference at ${path3}.${key}`);
@@ -9732,8 +9857,8 @@ async function dataProfiles(api, maxPages2) {
9732
9857
  }
9733
9858
  throw new Error("DLP inventory is incomplete; increase --max-pages");
9734
9859
  }
9735
- async function planRuntimeProfilesRestore(api, input, destinationTsgId, options = {}) {
9736
- const backup = RuntimeProfilesBackupSchema.parse(input);
9860
+ async function planRuntimeProfilesRestore(api, input2, destinationTsgId, options = {}) {
9861
+ const backup = RuntimeProfilesBackupSchema.parse(input2);
9737
9862
  if (!destinationTsgId) throw new Error("Destination TSG ID is required");
9738
9863
  const maxPages2 = options.maxPages ?? 100;
9739
9864
  pagesLimit(maxPages2);
@@ -9741,7 +9866,10 @@ async function planRuntimeProfilesRestore(api, input, destinationTsgId, options
9741
9866
  if (prefix.length > 64 || [...prefix].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127))
9742
9867
  throw new Error("Invalid --name-prefix");
9743
9868
  const conflict = options.onConflict ?? "error";
9744
- if (!["error", "skip", "update"].includes(conflict)) throw new Error("Invalid conflict policy");
9869
+ if (!["error", "skip", "update", "verify"].includes(conflict))
9870
+ throw new Error("Invalid conflict policy");
9871
+ const missingDlp = options.onMissingDlp ?? "error";
9872
+ if (!["error", "basic"].includes(missingDlp)) throw new Error("Invalid missing DLP policy");
9745
9873
  const selected = latestProfiles(backup.profiles);
9746
9874
  assertTenant(selected, backup.source.tsgId);
9747
9875
  if (selected.length !== backup.profiles.length)
@@ -9763,14 +9891,14 @@ async function planRuntimeProfilesRestore(api, input, destinationTsgId, options
9763
9891
  const match = existing.find((p) => p.profile_name === name);
9764
9892
  if (match && conflict === "error")
9765
9893
  throw new Error(
9766
- `Destination profile already exists: ${name}; choose --on-conflict skip or update`
9894
+ `Destination profile already exists: ${name}; choose --on-conflict verify, skip or update`
9767
9895
  );
9768
9896
  if (match && !match.profile_id) throw new Error("Destination profile has no identity");
9769
9897
  return {
9770
9898
  source: structuredClone(source),
9771
9899
  name,
9772
9900
  existing: match,
9773
- action: match ? conflict === "skip" ? "skip" : "update" : "create"
9901
+ action: match ? conflict === "skip" ? "skip" : conflict === "verify" ? "verify" : "update" : "create"
9774
9902
  };
9775
9903
  });
9776
9904
  const changing = profilePlans.filter((p) => p.action !== "skip");
@@ -9804,46 +9932,102 @@ async function planRuntimeProfilesRestore(api, input, destinationTsgId, options
9804
9932
  );
9805
9933
  topicPlans.set(key, { key, name, source, existing: match });
9806
9934
  }
9807
- const dlpRefs = crossTenant ? changing.flatMap((p) => dlpReferences(p.source.policy)) : [];
9935
+ const dlpRefs = crossTenant || missingDlp === "basic" ? changing.flatMap((p) => dlpReferences(p.source.policy)) : [];
9808
9936
  const names = [...new Set(dlpRefs.map((ref) => ref.name))];
9809
- for (const name of names)
9810
- if (!options.dlpMap?.[name])
9937
+ const targetFor = (name) => Object.hasOwn(options.dlpMap ?? {}, name) ? options.dlpMap?.[name] : crossTenant ? void 0 : name;
9938
+ const unresolved = /* @__PURE__ */ new Set();
9939
+ for (const name of names) {
9940
+ if (!targetFor(name) && missingDlp === "error")
9811
9941
  throw new Error(
9812
- `Cross-tenant DLP reference requires explicit --dlp-map ${name}=<destination-name>; DLP resources are not cloned`
9942
+ `Cross-tenant DLP reference requires explicit --dlp-map "${name}=<destination-name>"; DLP resources are not cloned. Inspect destination resources with airs runtime dlp profiles list --all --output json, or explicitly accept --on-missing-dlp basic.`
9813
9943
  );
9814
- const catalog = names.length ? await dataProfiles(api, maxPages2) : [];
9815
- const dlpMappings = names.map((name) => {
9816
- const target = options.dlpMap?.[name];
9817
- const matches = catalog.filter((p) => p.name === target && p.profile_status !== "deleted");
9944
+ if (!targetFor(name)) unresolved.add(name);
9945
+ }
9946
+ const catalog = names.some((name) => targetFor(name)) ? await dataProfiles(api, maxPages2) : [];
9947
+ const resolved = [];
9948
+ for (const name of names) {
9949
+ const target = targetFor(name);
9950
+ if (!target) continue;
9951
+ const matches = catalog.filter(
9952
+ (p) => p.name === target && p.profile_status !== "deleted" && p.profile_status !== "disabled"
9953
+ );
9954
+ if (!matches.length && missingDlp === "basic") {
9955
+ unresolved.add(name);
9956
+ continue;
9957
+ }
9818
9958
  if (matches.length !== 1 || !matches[0].id)
9819
9959
  throw new Error(`Missing or ambiguous destination DLP profile: ${target}`);
9820
9960
  if (dlpRefs.some((ref) => ref.name === name && "version" in ref.value) && matches[0].version == null)
9821
9961
  throw new Error(`Destination DLP version is unknown: ${target}`);
9822
- return {
9962
+ resolved.push({
9823
9963
  source: name,
9824
9964
  destination: String(target),
9825
9965
  id: matches[0].id,
9826
9966
  version: matches[0].version ?? void 0
9827
- };
9828
- });
9829
- return {
9967
+ });
9968
+ }
9969
+ const dlpFallbacks = [];
9970
+ for (const item of changing) {
9971
+ const replaced = [...new Set(dlpReferences(item.source.policy).map((ref) => ref.name))];
9972
+ const missing = replaced.filter((name) => unresolved.has(name));
9973
+ if (!missing.length) continue;
9974
+ item.fallbackPolicy = basicFallback(item.source.policy);
9975
+ dlpFallbacks.push({
9976
+ profile: item.name,
9977
+ unresolved: missing,
9978
+ replaced,
9979
+ reason: "unmapped-or-missing",
9980
+ protection: "basic"
9981
+ });
9982
+ }
9983
+ const remaining = new Set(
9984
+ changing.flatMap(
9985
+ (p) => dlpReferences(p.fallbackPolicy ?? p.source.policy).map((ref) => ref.name)
9986
+ )
9987
+ );
9988
+ const dlpMappings = resolved.filter((mapping) => remaining.has(mapping.source));
9989
+ const plan = {
9830
9990
  sourceTsgId: backup.source.tsgId,
9831
9991
  destinationTsgId,
9832
9992
  profiles: profilePlans,
9833
9993
  topics: [...topicPlans.values()],
9834
9994
  dlpMappings,
9995
+ dlpFallbacks,
9996
+ serverDefaults: [],
9835
9997
  maxPages: maxPages2
9836
9998
  };
9999
+ const existingTopics = new Map(
10000
+ plan.topics.filter((t) => t.existing).map((t) => [t.key, t.existing])
10001
+ );
10002
+ for (const item of profilePlans.filter((p) => p.action === "verify")) {
10003
+ const expected = mappedPolicy(item, plan, existingTopics);
10004
+ const comparison = compareRuntimePolicies(expected, item.existing?.policy);
10005
+ if (!comparison.matches || item.source.active !== void 0 && item.existing?.active !== item.source.active)
10006
+ throw new Error(
10007
+ `Destination profile does not match restore plan: ${item.name}; verification made no changes`
10008
+ );
10009
+ if (comparison.serverDefaults.length)
10010
+ plan.serverDefaults.push({ profile: item.name, fields: comparison.serverDefaults });
10011
+ }
10012
+ return plan;
9837
10013
  }
9838
10014
  function canonical(value) {
9839
- const order = (v) => Array.isArray(v) ? v.map(order) : object(v) ? Object.fromEntries(
10015
+ const order = (v) => Array.isArray(v) ? v.map(order) : object2(v) ? Object.fromEntries(
9840
10016
  Object.entries(v).sort(([a], [b]) => a.localeCompare(b)).map(([k, child]) => [k, order(child)])
9841
10017
  ) : v;
9842
10018
  return JSON.stringify(order(value));
9843
10019
  }
10020
+ function canonicalPolicy(policy) {
10021
+ const value = structuredClone(policy);
10022
+ for (const entry of value?.["ai-security-profiles"] ?? []) {
10023
+ const protection = entry["model-configuration"]?.["data-protection"];
10024
+ if (protection && !("database-security" in protection)) protection["database-security"] = null;
10025
+ }
10026
+ return canonical(value);
10027
+ }
9844
10028
  function unchanged(current, prior) {
9845
10029
  return Boolean(
9846
- current && current.profile_id === prior.profile_id && current.revision === prior.revision && current.active === prior.active && canonical(current.policy) === canonical(prior.policy)
10030
+ current && current.profile_id === prior.profile_id && current.revision === prior.revision && current.active === prior.active && canonicalPolicy(current.policy) === canonicalPolicy(prior.policy)
9847
10031
  );
9848
10032
  }
9849
10033
  async function restoreRuntimeProfiles(api, plan) {
@@ -9851,6 +10035,8 @@ async function restoreRuntimeProfiles(api, plan) {
9851
10035
  sourceTsgId: plan.sourceTsgId,
9852
10036
  destinationTsgId: plan.destinationTsgId,
9853
10037
  complete: false,
10038
+ dlpFallbacks: structuredClone(plan.dlpFallbacks ?? []),
10039
+ serverDefaults: [],
9854
10040
  topics: [],
9855
10041
  profiles: []
9856
10042
  };
@@ -9861,7 +10047,7 @@ async function restoreRuntimeProfiles(api, plan) {
9861
10047
  const catalog = await dataProfiles(api, plan.maxPages);
9862
10048
  for (const mapping of plan.dlpMappings) {
9863
10049
  const matches = catalog.filter(
9864
- (p) => p.name === mapping.destination && p.profile_status !== "deleted"
10050
+ (p) => p.name === mapping.destination && p.profile_status !== "deleted" && p.profile_status !== "disabled"
9865
10051
  );
9866
10052
  if (matches.length !== 1 || matches[0].id !== mapping.id || (matches[0].version ?? void 0) !== mapping.version)
9867
10053
  throw new Error(`Destination DLP profile changed after planning: ${mapping.destination}`);
@@ -9907,27 +10093,7 @@ async function restoreRuntimeProfiles(api, plan) {
9907
10093
  result.profiles.push({ name: item.name, action: "skipped", id: item.existing?.profile_id });
9908
10094
  continue;
9909
10095
  }
9910
- const policy = structuredClone(item.source.policy);
9911
- for (const ref of topicReferences(policy)) {
9912
- const topic = mappedTopics.get(topicKey(ref));
9913
- if (!topic?.topic_id) throw new Error("Missing planned topic mapping");
9914
- ref.topic_id = topic.topic_id;
9915
- ref.topic_name = topic.topic_name;
9916
- ref.revision = topic.revision;
9917
- }
9918
- for (const ref of dlpReferences(policy)) {
9919
- const mapping = plan.dlpMappings.find((m) => m.source === ref.name);
9920
- if (!mapping) continue;
9921
- if (ref.kind === "embedded") {
9922
- ref.value.name = mapping.destination;
9923
- ref.value.uuid = mapping.id;
9924
- } else ref.value.text = mapping.destination;
9925
- if ("id" in ref.value) ref.value.id = mapping.id;
9926
- if ("version" in ref.value) {
9927
- if (mapping.version === void 0) throw new Error("Destination DLP version is unknown");
9928
- ref.value.version = String(mapping.version);
9929
- }
9930
- }
10096
+ const policy = mappedPolicy(item, plan, mappedTopics);
9931
10097
  const body = CreateSecurityProfileRequestSchema.parse({
9932
10098
  profile_name: item.name,
9933
10099
  active: item.source.active,
@@ -9938,6 +10104,14 @@ async function restoreRuntimeProfiles(api, plan) {
9938
10104
  const current = inventory2.find((p) => p.profile_name === item.name);
9939
10105
  if (item.existing ? !unchanged(current, item.existing) : Boolean(current))
9940
10106
  throw new Error(`Destination profile changed after planning: ${item.name}`);
10107
+ if (item.action === "verify") {
10108
+ const comparison2 = compareRuntimePolicies(policy, current?.policy);
10109
+ if (!comparison2.matches) throw new Error(`Restored profile did not verify: ${item.name}`);
10110
+ result.profiles.push({ name: item.name, action: "verified", id: current?.profile_id });
10111
+ if (comparison2.serverDefaults.length)
10112
+ result.serverDefaults.push({ profile: item.name, fields: comparison2.serverDefaults });
10113
+ continue;
10114
+ }
9941
10115
  const response = item.action === "update" && item.existing?.profile_id ? await api.profiles.update(item.existing.profile_id, body) : await api.profiles.create(body);
9942
10116
  result.profiles.push({
9943
10117
  name: item.name,
@@ -9948,8 +10122,11 @@ async function restoreRuntimeProfiles(api, plan) {
9948
10122
  (p) => p.profile_name === item.name
9949
10123
  );
9950
10124
  if (verified) assertTenant([verified], plan.destinationTsgId);
9951
- if (!verified?.profile_id || response.profile_id && response.profile_id !== verified.profile_id || item.source.active !== void 0 && verified.active !== item.source.active || canonical(verified.policy) !== canonical(policy))
10125
+ const comparison = compareRuntimePolicies(policy, verified?.policy);
10126
+ if (!verified?.profile_id || response.profile_id && response.profile_id !== verified.profile_id || item.source.active !== void 0 && verified.active !== item.source.active || !comparison.matches)
9952
10127
  throw new Error(`Restored profile did not verify: ${item.name}`);
10128
+ if (comparison.serverDefaults.length)
10129
+ result.serverDefaults.push({ profile: item.name, fields: comparison.serverDefaults });
9953
10130
  }
9954
10131
  result.complete = true;
9955
10132
  } catch (error) {
@@ -9960,10 +10137,97 @@ async function restoreRuntimeProfiles(api, plan) {
9960
10137
  }
9961
10138
  return result;
9962
10139
  }
10140
+ function mappedPolicy(item, plan, mappedTopics) {
10141
+ const policy = structuredClone(item.fallbackPolicy ?? item.source.policy);
10142
+ for (const ref of topicReferences(policy)) {
10143
+ const topic = mappedTopics.get(topicKey(ref));
10144
+ if (!topic?.topic_id)
10145
+ throw new Error(
10146
+ "Missing planned topic mapping; an existing profile cannot be verified without its destination dependencies"
10147
+ );
10148
+ ref.topic_id = topic.topic_id;
10149
+ ref.topic_name = topic.topic_name;
10150
+ ref.revision = topic.revision;
10151
+ }
10152
+ for (const ref of dlpReferences(policy)) {
10153
+ const mapping = plan.dlpMappings.find((m) => m.source === ref.name);
10154
+ if (!mapping) continue;
10155
+ if (ref.kind === "embedded") {
10156
+ ref.value.name = mapping.destination;
10157
+ ref.value.uuid = mapping.id;
10158
+ } else ref.value.text = mapping.destination;
10159
+ if ("id" in ref.value) ref.value.id = mapping.id;
10160
+ if ("version" in ref.value) {
10161
+ if (mapping.version === void 0) throw new Error("Destination DLP version is unknown");
10162
+ ref.value.version = String(mapping.version);
10163
+ }
10164
+ }
10165
+ return policy;
10166
+ }
9963
10167
 
9964
10168
  // src/cli/commands/profile-transfer.ts
9965
10169
  var MAX_BACKUP_BYTES = 20 * 1024 * 1024;
9966
10170
  var FORMATS = "Output format: pretty, table, markdown, csv, json, yaml";
10171
+ function renderRestore(value, columns, format) {
10172
+ if (format !== "pretty" && format !== "table" && format !== "markdown")
10173
+ return formatOutput([value], columns, format);
10174
+ const markdown = format === "markdown";
10175
+ const rows = (key) => Array.isArray(value[key]) ? value[key] : [];
10176
+ const heading = (text2) => markdown ? `### ${text2}
10177
+ ` : text2;
10178
+ const blocks = [
10179
+ heading(
10180
+ value.dryRun ? "Restore preview \u2014 no changes made" : value.complete ? "Restore complete" : "Restore incomplete \u2014 completed writes remain"
10181
+ ),
10182
+ `Source TSG: ${value.sourceTsgId}
10183
+ ${markdown ? "\n" : ""}Destination TSG: ${value.destinationTsgId}`
10184
+ ];
10185
+ for (const [key, title] of [
10186
+ ["profiles", "Profiles"],
10187
+ ["topics", "Topics"]
10188
+ ]) {
10189
+ blocks.push(heading(`${title} (${rows(key).length})`));
10190
+ blocks.push(
10191
+ formatOutput(
10192
+ rows(key),
10193
+ [
10194
+ { key: "action", label: "Action" },
10195
+ { key: "name", label: "Name" }
10196
+ ],
10197
+ markdown ? "markdown" : "table"
10198
+ ) || "(none)"
10199
+ );
10200
+ }
10201
+ if (rows("dlpMappings").length) {
10202
+ blocks.push(heading("DLP mappings"));
10203
+ blocks.push(
10204
+ formatOutput(
10205
+ rows("dlpMappings"),
10206
+ [
10207
+ { key: "source", label: "Source" },
10208
+ { key: "destination", label: "Destination" }
10209
+ ],
10210
+ markdown ? "markdown" : "table"
10211
+ )
10212
+ );
10213
+ }
10214
+ if (rows("dlpFallbacks").length) {
10215
+ blocks.push(heading("Basic DLP fallbacks \u2014 custom protection lost"));
10216
+ for (const fallback of rows("dlpFallbacks"))
10217
+ blocks.push(
10218
+ `${fallback.profile}: ${Array.isArray(fallback.replaced) ? fallback.replaced.join(", ") : ""}`
10219
+ );
10220
+ }
10221
+ if (rows("serverDefaults").length) {
10222
+ blocks.push(heading("Verified server-added defaults (source omitted these fields)"));
10223
+ for (const item of rows("serverDefaults"))
10224
+ blocks.push(
10225
+ `${item.profile}: ${Array.isArray(item.fields) ? item.fields.length : 0} fields; use --output json for paths`
10226
+ );
10227
+ }
10228
+ if (value.error) blocks.push(`Error: ${value.error}`);
10229
+ return blocks.join("\n\n");
10230
+ }
9967
10231
  function maxPages(value) {
9968
10232
  if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 1e3)
9969
10233
  usageError("--max-pages must be an integer from 1 to 1000");
@@ -10076,7 +10340,15 @@ function registerProfileTransferCommands(profiles2) {
10076
10340
  });
10077
10341
  const restore = profiles2.command("restore <file>").description(
10078
10342
  "Restore profile policies into the selected tenant; validate all dependencies before changes"
10079
- ).option("--dry-run", "Read and validate a restore plan without changing destination resources").option("--name-prefix <prefix>", "Prefix restored profile and topic names to avoid collisions").option("--on-conflict <policy>", "Existing profile names: error, skip or update", "error").option(
10343
+ ).option("--dry-run", "Read and validate a restore plan without changing destination resources").option("--name-prefix <prefix>", "Prefix restored profile and topic names to avoid collisions").option(
10344
+ "--on-conflict <policy>",
10345
+ "Existing profile names: error, verify (resume without writes), skip or update",
10346
+ "error"
10347
+ ).option(
10348
+ "--on-missing-dlp <policy>",
10349
+ "Unresolved custom DLP: error or basic (explicitly accepts loss of custom detection)",
10350
+ "error"
10351
+ ).option(
10080
10352
  "--dlp-map <source=destination>",
10081
10353
  "Explicit cross-tenant binding to an existing DLP data profile (repeatable)",
10082
10354
  (value, prior) => [...prior, value],
@@ -10093,12 +10365,15 @@ function registerProfileTransferCommands(profiles2) {
10093
10365
  "after",
10094
10366
  examples(
10095
10367
  "airs runtime profiles restore ./profiles.json --dry-run --output json",
10368
+ "airs runtime profiles restore ./profiles.json --on-missing-dlp basic --dry-run --output json",
10096
10369
  "airs runtime profiles restore ./profiles.json --name-prefix migrated- --expect-tsg <destination-tsg> --force"
10097
10370
  )
10098
10371
  ).action(async (file, opts) => {
10099
10372
  try {
10100
- if (!["error", "skip", "update"].includes(opts.onConflict))
10101
- throw new CliUsageError("--on-conflict must be error, skip or update");
10373
+ if (!["error", "skip", "update", "verify"].includes(opts.onConflict))
10374
+ throw new CliUsageError("--on-conflict must be error, verify, skip or update");
10375
+ if (!["error", "basic"].includes(opts.onMissingDlp))
10376
+ throw new CliUsageError("--on-missing-dlp must be error or basic");
10102
10377
  if (opts.force && !opts.expectTsg)
10103
10378
  throw new CliUsageError("--force requires --expect-tsg <destination-tsg>");
10104
10379
  const mappings = dlpMap(opts.dlpMap);
@@ -10109,15 +10384,15 @@ function registerProfileTransferCommands(profiles2) {
10109
10384
  const info = await stat(path3);
10110
10385
  if (!info.isFile() || info.size > MAX_BACKUP_BYTES)
10111
10386
  throw new CliUsageError("Backup must be a regular file no larger than 20 MiB");
10112
- let input;
10387
+ let input2;
10113
10388
  try {
10114
10389
  const text2 = await readFile9(path3, "utf8");
10115
10390
  if (Buffer.byteLength(text2) > MAX_BACKUP_BYTES) throw new Error("Backup too large");
10116
- input = extension === ".json" ? JSON.parse(text2) : load2(text2, { schema: JSON_SCHEMA });
10391
+ input2 = extension === ".json" ? JSON.parse(text2) : load2(text2, { schema: JSON_SCHEMA });
10117
10392
  } catch {
10118
10393
  throw new CliUsageError("Backup is not valid JSON/YAML; contents are not logged");
10119
10394
  }
10120
- const data = RuntimeProfilesBackupSchema.parse(input);
10395
+ const data = RuntimeProfilesBackupSchema.parse(input2);
10121
10396
  const format = await resolveOutput(restore, opts);
10122
10397
  const { api, tsgId } = await context();
10123
10398
  if (opts.expectTsg && opts.expectTsg !== tsgId)
@@ -10128,7 +10403,8 @@ function registerProfileTransferCommands(profiles2) {
10128
10403
  namePrefix: opts.namePrefix,
10129
10404
  onConflict: opts.onConflict,
10130
10405
  maxPages: opts.maxPages,
10131
- dlpMap: mappings
10406
+ dlpMap: mappings,
10407
+ onMissingDlp: opts.onMissingDlp
10132
10408
  });
10133
10409
  const summary = {
10134
10410
  sourceTsgId: plan.sourceTsgId,
@@ -10142,7 +10418,9 @@ function registerProfileTransferCommands(profiles2) {
10142
10418
  dlpMappings: plan.dlpMappings.map((m) => ({
10143
10419
  source: m.source,
10144
10420
  destination: m.destination
10145
- }))
10421
+ })),
10422
+ dlpFallbacks: plan.dlpFallbacks,
10423
+ serverDefaults: plan.serverDefaults
10146
10424
  };
10147
10425
  const columns = [
10148
10426
  { key: "sourceTsgId", label: "Source TSG" },
@@ -10150,22 +10428,30 @@ function registerProfileTransferCommands(profiles2) {
10150
10428
  { key: "profiles", label: "Profiles" },
10151
10429
  { key: "topics", label: "Topics" },
10152
10430
  { key: "dlpMappings", label: "DLP mappings" },
10431
+ { key: "dlpFallbacks", label: "Basic DLP fallbacks (custom protection lost)" },
10432
+ { key: "serverDefaults", label: "Verified server-added defaults" },
10153
10433
  { key: "dryRun", label: "Dry run" },
10154
10434
  { key: "complete", label: "Complete" }
10155
10435
  ];
10436
+ for (const fallback of plan.dlpFallbacks)
10437
+ ui.warning(
10438
+ `DLP protection change: ${fallback.profile} will use Basic detection instead of ${fallback.replaced.join(", ")}. Custom rules are not preserved.`
10439
+ );
10156
10440
  if (opts.dryRun) {
10157
- console.log(formatOutput([summary], columns, format === "pretty" ? "table" : format));
10441
+ console.log(renderRestore(summary, columns, format));
10158
10442
  return;
10159
10443
  }
10160
- const changing = plan.profiles.filter((p) => p.action !== "skip");
10444
+ const changing = plan.profiles.filter(
10445
+ (p) => p.action === "create" || p.action === "update"
10446
+ );
10161
10447
  if (changing.length)
10162
10448
  await confirmOrAbort(
10163
- `Restore ${changing.length} profiles from TSG ${plan.sourceTsgId} to TSG ${tsgId} (${changing.filter((p) => p.action === "update").length} updates)?`,
10449
+ `Restore ${changing.length} profiles from TSG ${plan.sourceTsgId} to TSG ${tsgId} (${changing.filter((p) => p.action === "update").length} updates, ${plan.dlpFallbacks.length} Basic DLP fallbacks losing custom detection)?`,
10164
10450
  Boolean(opts.force),
10165
10451
  { action: `restore profiles into TSG ${tsgId}` }
10166
10452
  );
10167
10453
  const result = await restoreRuntimeProfiles(api, plan);
10168
- console.log(formatOutput([{ ...result }], columns, format === "pretty" ? "table" : format));
10454
+ console.log(renderRestore({ ...result }, columns, format));
10169
10455
  if (!result.complete) {
10170
10456
  ui.error(result.error ?? "Restore incomplete");
10171
10457
  process.exitCode = 1;
@@ -10608,26 +10894,26 @@ function registerRuntimeReportCommand(runtime) {
10608
10894
  }
10609
10895
 
10610
10896
  // src/cli/commands/topics-apply.ts
10611
- async function applyTopicToProfile(mgmt, input) {
10897
+ async function applyTopicToProfile(mgmt, input2) {
10612
10898
  const allTopics = await mgmt.listTopics();
10613
- const match = allTopics.find((t) => t.topic_name === input.topicName);
10899
+ const match = allTopics.find((t) => t.topic_name === input2.topicName);
10614
10900
  if (!match || !match.topic_id) {
10615
- throw new Error(`Topic "${input.topicName}" not found. Create it first with "topics create".`);
10901
+ throw new Error(`Topic "${input2.topicName}" not found. Create it first with "topics create".`);
10616
10902
  }
10617
- const existing = await mgmt.getProfileTopics(input.profileName);
10618
- const merged = existing.filter((t) => t.topicName !== input.topicName).map((t) => ({ topicId: t.topicId, topicName: t.topicName, action: t.action }));
10903
+ const existing = await mgmt.getProfileTopics(input2.profileName);
10904
+ const merged = existing.filter((t) => t.topicName !== input2.topicName).map((t) => ({ topicId: t.topicId, topicName: t.topicName, action: t.action }));
10619
10905
  merged.push({
10620
10906
  topicId: match.topic_id,
10621
10907
  topicName: match.topic_name,
10622
- action: input.intent
10908
+ action: input2.intent
10623
10909
  });
10624
- const guardrailAction = input.intent === "block" ? "allow" : "block";
10625
- await mgmt.assignTopicsToProfile(input.profileName, merged, guardrailAction);
10910
+ const guardrailAction = input2.intent === "block" ? "allow" : "block";
10911
+ await mgmt.assignTopicsToProfile(input2.profileName, merged, guardrailAction);
10626
10912
  return {
10627
10913
  topicId: match.topic_id,
10628
10914
  topicName: match.topic_name,
10629
- profileName: input.profileName,
10630
- intent: input.intent
10915
+ profileName: input2.profileName,
10916
+ intent: input2.intent
10631
10917
  };
10632
10918
  }
10633
10919
  function registerApplyCommand(parent) {
@@ -10672,21 +10958,21 @@ function registerApplyCommand(parent) {
10672
10958
  }
10673
10959
 
10674
10960
  // src/cli/commands/topics-create.ts
10675
- async function createOrUpdateTopic(mgmt, input) {
10676
- const topic = { name: input.name, description: input.description, examples: input.examples };
10961
+ async function createOrUpdateTopic(mgmt, input2) {
10962
+ const topic = { name: input2.name, description: input2.description, examples: input2.examples };
10677
10963
  const errors = validateTopic(topic);
10678
10964
  if (errors.length > 0) {
10679
10965
  throw new Error(errors.map((e) => `${e.field}: ${e.message}`).join("; "));
10680
10966
  }
10681
10967
  const existing = await mgmt.listTopics();
10682
- const match = existing.find((t) => t.topic_name === input.name);
10968
+ const match = existing.find((t) => t.topic_name === input2.name);
10683
10969
  const request = {
10684
- topic_name: input.name,
10685
- description: input.description,
10686
- examples: input.examples
10970
+ topic_name: input2.name,
10971
+ description: input2.description,
10972
+ examples: input2.examples
10687
10973
  };
10688
10974
  if (match && !match.topic_id) {
10689
- throw new Error(`Existing topic '${input.name}' has no topic_id`);
10975
+ throw new Error(`Existing topic '${input2.name}' has no topic_id`);
10690
10976
  }
10691
10977
  const result = match ? await mgmt.updateTopic(match.topic_id, request) : await mgmt.createTopic(request);
10692
10978
  if (!result.topic_id) {
@@ -11843,7 +12129,139 @@ function registerRuntimeCommand(program) {
11843
12129
  registerDlpCommands(runtime);
11844
12130
  }
11845
12131
 
12132
+ // src/config/tenant-settings.ts
12133
+ import { randomUUID as randomUUID10 } from "crypto";
12134
+ import { constants } from "fs";
12135
+ import { access, lstat as lstat6, mkdir as mkdir3, open as open2, rename as rename2, unlink as unlink2 } from "fs/promises";
12136
+ import { dirname as dirname4, join as join4 } from "path";
12137
+ var TENANT_CONFIG_KEYS = Object.keys(ConfigSchema.shape);
12138
+ function validateTenantSettingKey(key) {
12139
+ if (!Object.hasOwn(ConfigSchema.shape, key))
12140
+ throw new Error(`Unknown configuration key. Supported keys: ${TENANT_CONFIG_KEYS.join(", ")}`);
12141
+ }
12142
+ function isTenantSecret(key) {
12143
+ return /(?:key|secret|token|password)$/i.test(key);
12144
+ }
12145
+ function validateCredentials(config) {
12146
+ for (const key of ["mgmtTsgId", "mgmtClientId", "mgmtClientSecret"]) {
12147
+ if (typeof config[key] !== "string" || !config[key].trim())
12148
+ throw new Error(
12149
+ "Tenant config requires nonempty mgmtTsgId, mgmtClientId and mgmtClientSecret"
12150
+ );
12151
+ }
12152
+ }
12153
+ async function createManagedTenant(name, config) {
12154
+ validateTenantName(name);
12155
+ for (const key of Object.keys(config)) validateTenantSettingKey(key);
12156
+ const parsed = ConfigSchema.safeParse(config);
12157
+ if (!parsed.success) throw new Error("Invalid tenant configuration");
12158
+ validateCredentials(config);
12159
+ if (readTenantStore().tenants.some((entry) => entry.name === name))
12160
+ throw new Error("Tenant name already exists");
12161
+ const directory = join4(dirname4(tenantStorePath()), "configs");
12162
+ await mkdir3(directory, { recursive: true, mode: 448 });
12163
+ const path3 = join4(directory, `${name}-${randomUUID10()}.json`);
12164
+ const file = await open2(path3, "wx", 384);
12165
+ try {
12166
+ const data = Object.fromEntries(
12167
+ Object.keys(config).map((key) => [key, Reflect.get(parsed.data, key)])
12168
+ );
12169
+ await file.writeFile(`${JSON.stringify(data, null, 2)}
12170
+ `);
12171
+ await file.sync();
12172
+ await file.close();
12173
+ return await createTenant(name, path3);
12174
+ } catch {
12175
+ await file.close();
12176
+ await unlink2(path3);
12177
+ throw new Error("Could not create tenant configuration; registration was not completed");
12178
+ }
12179
+ }
12180
+ async function setTenantSetting(name, key, value) {
12181
+ validateTenantSettingKey(key);
12182
+ const entry = readTenantStore().tenants.find((tenant) => tenant.name === name);
12183
+ if (!entry) throw new Error("Tenant not found; register a named tenant first");
12184
+ if (key === "mgmtTsgId" && value !== entry.tsgId)
12185
+ throw new Error("TSG identity is pinned; create another tenant instead of changing mgmtTsgId");
12186
+ const path3 = entry.configPath;
12187
+ const lockPath = `${path3}.lock`;
12188
+ const lock = await open2(lockPath, "wx", 384).catch(() => {
12189
+ throw new Error(
12190
+ "Cannot lock tenant config; it may be read-only or another edit is in progress"
12191
+ );
12192
+ });
12193
+ const temporary = join4(dirname4(path3), `.airs-config-${randomUUID10()}.tmp`);
12194
+ let created = false;
12195
+ try {
12196
+ const stat2 = await lstat6(path3);
12197
+ if (!stat2.isFile() || (stat2.mode & 146) === 0)
12198
+ throw new Error("Tenant config is not a writable regular file");
12199
+ await access(path3, constants.W_OK);
12200
+ const current = readTenantConfigFile(path3, entry.tsgId);
12201
+ const result = ConfigSchema.safeParse({ ...current, [key]: value });
12202
+ if (!result.success) throw new Error("Invalid value for configuration setting");
12203
+ const next = { ...current, [key]: Reflect.get(result.data, key) };
12204
+ validateCredentials(next);
12205
+ const file = await open2(temporary, "wx", 384);
12206
+ created = true;
12207
+ try {
12208
+ await file.writeFile(`${JSON.stringify(next, null, 2)}
12209
+ `);
12210
+ await file.sync();
12211
+ } finally {
12212
+ await file.close();
12213
+ }
12214
+ await rename2(temporary, path3);
12215
+ created = false;
12216
+ } finally {
12217
+ if (created) await unlink2(temporary).catch(() => {
12218
+ });
12219
+ await lock.close();
12220
+ await unlink2(lockPath);
12221
+ }
12222
+ }
12223
+
12224
+ // src/cli/tenant-input.ts
12225
+ import { input, password } from "@inquirer/prompts";
12226
+ async function promptTenantValue(message, secret = false) {
12227
+ if (!process.stdin.isTTY || !process.stderr.isTTY)
12228
+ throw new Error(
12229
+ "Interactive setup requires a terminal. Use --config, or --tsg-id and --client-id with --client-secret-stdin. For tenant set, pass a value or use --stdin."
12230
+ );
12231
+ const prompt = secret ? password : input;
12232
+ return prompt(
12233
+ {
12234
+ message,
12235
+ validate: (value) => Boolean(value.trim()) || "A nonempty value is required"
12236
+ },
12237
+ { input: process.stdin, output: process.stderr }
12238
+ );
12239
+ }
12240
+ async function readTenantStdin() {
12241
+ if (process.stdin.isTTY) throw new Error("--stdin requires piped input");
12242
+ const chunks = [];
12243
+ let size = 0;
12244
+ for await (const chunk of process.stdin) {
12245
+ const buffer = Buffer.from(chunk);
12246
+ size += buffer.length;
12247
+ if (size > 65536) throw new Error("Configuration input exceeds 64 KiB");
12248
+ chunks.push(buffer);
12249
+ }
12250
+ const value = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
12251
+ if (!value.trim() || /[\r\n\0]/.test(value))
12252
+ throw new Error("Provide one nonempty configuration value on stdin");
12253
+ return value;
12254
+ }
12255
+
11846
12256
  // src/cli/commands/tenant.ts
12257
+ function tenantInputFailure(error) {
12258
+ if (error instanceof Error && error.name === "ExitPromptError") {
12259
+ ui.status("Cancelled; no configuration saved.");
12260
+ process.exitCode = 130;
12261
+ return;
12262
+ }
12263
+ fail(error);
12264
+ }
11847
12265
  var COLUMNS2 = [
11848
12266
  { key: "name", label: "Tenant" },
11849
12267
  { key: "active", label: "Selected" },
@@ -11851,25 +12269,60 @@ var COLUMNS2 = [
11851
12269
  { key: "configPath", label: "Config file" }
11852
12270
  ];
11853
12271
  function registerTenantCommand(program) {
11854
- const tenant = program.command("tenant").description("Select named tenant config files without changing their contents").addHelpText(
12272
+ const tenant = program.command("tenant").description("Create, configure and select named tenants").addHelpText(
11855
12273
  "after",
11856
12274
  examples(
12275
+ "airs tenant create development",
12276
+ "airs tenant set development defaultOutput yaml",
11857
12277
  "airs tenant create production --config /secure/production.json",
11858
12278
  "airs tenant switch production",
11859
12279
  "airs tenant read",
11860
12280
  "airs tenant switch default"
11861
12281
  )
11862
12282
  );
11863
- tenant.command("create <name>").description("Register an existing JSON config file by reference; does not activate it").requiredOption("--config <path>", "Existing Prisma AIRS JSON config file").action(async (name, opts) => {
11864
- try {
11865
- const entry = await createTenant(name, opts.config);
11866
- ui.success(
11867
- `Registered ${entry.name} (TSG ${entry.tsgId}); config file unchanged. Use airs tenant switch ${entry.name}.`
11868
- );
11869
- } catch (error) {
11870
- fail(error);
12283
+ tenant.command("create <name>").description(
12284
+ "Create a config with guided prompts, or register an existing JSON file; does not activate it"
12285
+ ).option("--config <path>", "Existing Prisma AIRS JSON config file (no copying or editing)").option("--tsg-id <id>", "Tenant service group ID for a new config").option("--client-id <id>", "OAuth client ID for a new config").option("--client-secret-stdin", "Read the new config OAuth secret from piped stdin").action(
12286
+ async (name, opts) => {
12287
+ try {
12288
+ validateTenantName(name);
12289
+ if (opts.config && (opts.tsgId !== void 0 || opts.clientId !== void 0 || opts.clientSecretStdin))
12290
+ throw new Error("--config cannot be combined with new-config options");
12291
+ if (!opts.config && readTenantStore().tenants.some((entry2) => entry2.name === name))
12292
+ throw new Error("Tenant name already exists");
12293
+ const entry = opts.config ? await createTenant(name, opts.config) : await createManagedTenant(name, {
12294
+ mgmtTsgId: opts.tsgId ?? await promptTenantValue("Tenant service group ID (mgmtTsgId):"),
12295
+ mgmtClientId: opts.clientId ?? await promptTenantValue("OAuth client ID (mgmtClientId):"),
12296
+ mgmtClientSecret: opts.clientSecretStdin ? await readTenantStdin() : await promptTenantValue("OAuth client secret (mgmtClientSecret):", true)
12297
+ });
12298
+ ui.success(
12299
+ `Registered ${entry.name} (TSG ${entry.tsgId}); ${opts.config ? "config file unchanged" : "private config created"}. Use airs tenant switch ${entry.name}.`
12300
+ );
12301
+ } catch (error) {
12302
+ tenantInputFailure(error);
12303
+ }
11871
12304
  }
11872
- });
12305
+ );
12306
+ tenant.command("set <name> <key> [value]").description("Update one named tenant setting; prompt when omitted, hide secrets").option("--stdin", "Read one value from piped stdin (recommended for automated secret updates)").action(
12307
+ async (name, key, value, opts) => {
12308
+ try {
12309
+ validateTenantSettingKey(key);
12310
+ if (!readTenantStore().tenants.some((entry) => entry.name === name))
12311
+ throw new Error("Tenant not found; register a named tenant first");
12312
+ if (opts.stdin && value !== void 0)
12313
+ throw new Error("Use a value or --stdin, not both");
12314
+ if (isTenantSecret(key) && value !== void 0)
12315
+ throw new Error(
12316
+ "Do not pass credentials as arguments. Omit the value for a hidden prompt, or use --stdin."
12317
+ );
12318
+ const setting = opts.stdin ? await readTenantStdin() : value ?? await promptTenantValue(`${key}:`, isTenantSecret(key));
12319
+ await setTenantSetting(name, key, setting);
12320
+ ui.success(`Updated ${key} for tenant ${name}; selection unchanged.`);
12321
+ } catch (error) {
12322
+ tenantInputFailure(error);
12323
+ }
12324
+ }
12325
+ );
11873
12326
  tenant.command("switch <name>").description(
11874
12327
  "Persist the selected tenant; default restores legacy config/environment resolution"
11875
12328
  ).action(async (name) => {
@@ -12005,8 +12458,8 @@ function applySortedHelp(cmd) {
12005
12458
  for (const sub of cmd.commands) applySortedHelp(sub);
12006
12459
  }
12007
12460
  function buildProgram() {
12008
- const here = dirname4(fileURLToPath(import.meta.url));
12009
- const pkg = JSON.parse(readFileSync4(join4(here, "../../package.json"), "utf-8"));
12461
+ const here = dirname5(fileURLToPath(import.meta.url));
12462
+ const pkg = JSON.parse(readFileSync4(join5(here, "../../package.json"), "utf-8"));
12010
12463
  const program = new Command();
12011
12464
  program.name("airs").description(
12012
12465
  "CLI and library for Palo Alto Prisma AIRS \u2014 guardrail refinement, AI red teaming, model security scanning, profile audits"
@@ -12035,9 +12488,9 @@ function buildProgram() {
12035
12488
  }
12036
12489
  }
12037
12490
  if (root.debug) {
12038
- const logPath = join4(
12491
+ const logPath = join5(
12039
12492
  process.cwd(),
12040
- `debug-api-${Date.now()}-${randomUUID10().slice(0, 8)}.jsonl`
12493
+ `debug-api-${Date.now()}-${randomUUID11().slice(0, 8)}.jsonl`
12041
12494
  );
12042
12495
  try {
12043
12496
  let ancestor = actionCommand;
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  validateTopic,
29
29
  writeBackupFile,
30
30
  writeReportFile
31
- } from "./chunk-NKAVUBKI.js";
31
+ } from "./chunk-XQOICLSH.js";
32
32
  export {
33
33
  AirsScanService,
34
34
  SDK_ASYNC_BATCH_SIZE,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cdot65/prisma-airs-cli",
3
3
  "packageManager": "pnpm@10.6.5",
4
- "version": "5.5.0",
4
+ "version": "5.7.0",
5
5
  "description": "CLI and library for Palo Alto Prisma AIRS — guardrail refinement, AI red teaming, model security scanning, profile audits",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "license": "MIT",
61
61
  "dependencies": {
62
- "@cdot65/prisma-airs-sdk": "0.29.0",
62
+ "@cdot65/prisma-airs-sdk": "0.30.0",
63
63
  "@inquirer/prompts": "^8.3.0",
64
64
  "chalk": "^5.6.2",
65
65
  "commander": "^14.0.3",