@cdot65/prisma-airs-cli 3.1.0 → 3.2.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/dist/cli/index.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  AirsScanService,
4
4
  ConfigSchema,
5
5
  RateLimitedScanService,
6
+ SDK_ASYNC_BATCH_SIZE,
6
7
  SdkManagementService,
7
8
  SdkModelSecurityService,
8
9
  SdkPromptSetService,
@@ -19,7 +20,7 @@ import {
19
20
  sanitizeFilename,
20
21
  validateTopic,
21
22
  writeBackupFile
22
- } from "../chunk-DSNQSBLE.js";
23
+ } from "../chunk-TTBN7YHC.js";
23
24
 
24
25
  // src/cli/index.ts
25
26
  import "dotenv/config";
@@ -40,7 +41,7 @@ function installProcessGuards() {
40
41
  // src/cli/program.ts
41
42
  import { readFileSync as readFileSync4 } from "fs";
42
43
  import { homedir } from "os";
43
- import { dirname as dirname3, join as join3 } from "path";
44
+ import { dirname as dirname4, join as join4 } from "path";
44
45
  import { fileURLToPath } from "url";
45
46
  import { Command } from "commander";
46
47
 
@@ -4551,8 +4552,10 @@ function registerRedteamCommand(program) {
4551
4552
  }
4552
4553
 
4553
4554
  // src/cli/commands/runtime.ts
4554
- import * as fs4 from "fs";
4555
- import { readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
4555
+ import { randomUUID as randomUUID4 } from "crypto";
4556
+ import * as fs5 from "fs";
4557
+ import { readFile as readFile8 } from "fs/promises";
4558
+ import { basename as basename3, dirname as dirname2, join as join2, resolve as resolvePath } from "path";
4556
4559
  import chalk10 from "chalk";
4557
4560
 
4558
4561
  // src/cli/builders/profile-builder.ts
@@ -4796,20 +4799,224 @@ function mergeProfilePolicy(existing, overrides) {
4796
4799
  return base;
4797
4800
  }
4798
4801
 
4799
- // src/cli/bulk-scan-state.ts
4802
+ // src/cli/bulk-scan-lock.ts
4803
+ import { randomUUID as randomUUID2 } from "crypto";
4800
4804
  import * as fs3 from "fs/promises";
4805
+ function errorCode(error) {
4806
+ return error?.code;
4807
+ }
4808
+ function parseLock(raw, lockPath) {
4809
+ let value;
4810
+ try {
4811
+ value = JSON.parse(raw);
4812
+ } catch {
4813
+ throw new Error(
4814
+ `Bulk-scan lock ${lockPath} is malformed. If no bulk-scan process is running, remove it manually.`
4815
+ );
4816
+ }
4817
+ const record = value;
4818
+ if (record.version !== 1 || !Number.isSafeInteger(record.pid) || (record.pid ?? 0) <= 0 || typeof record.createdAt !== "string" || typeof record.token !== "string" || record.token.length === 0) {
4819
+ throw new Error(
4820
+ `Bulk-scan lock ${lockPath} has invalid ownership data. If no bulk-scan process is running, remove it manually.`
4821
+ );
4822
+ }
4823
+ return record;
4824
+ }
4825
+ function processIsAlive(pid) {
4826
+ try {
4827
+ process.kill(pid, 0);
4828
+ return true;
4829
+ } catch (error) {
4830
+ return errorCode(error) !== "ESRCH";
4831
+ }
4832
+ }
4833
+ async function installLock(lockPath, record) {
4834
+ const candidate = `${lockPath}.candidate-${process.pid}-${randomUUID2()}`;
4835
+ try {
4836
+ await fs3.writeFile(candidate, JSON.stringify(record), {
4837
+ encoding: "utf-8",
4838
+ flag: "wx",
4839
+ mode: 384
4840
+ });
4841
+ await fs3.link(candidate, lockPath);
4842
+ } finally {
4843
+ await fs3.rm(candidate, { force: true });
4844
+ }
4845
+ }
4846
+ async function acquireBulkScanLock(statePath) {
4847
+ const lockPath = `${statePath}.lock`;
4848
+ const record = {
4849
+ version: 1,
4850
+ pid: process.pid,
4851
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4852
+ token: randomUUID2()
4853
+ };
4854
+ for (let attempt = 0; attempt < 2; attempt++) {
4855
+ try {
4856
+ await installLock(lockPath, record);
4857
+ return async () => {
4858
+ let current;
4859
+ try {
4860
+ current = parseLock(await fs3.readFile(lockPath, "utf-8"), lockPath);
4861
+ } catch (error) {
4862
+ if (errorCode(error) === "ENOENT") return;
4863
+ throw error;
4864
+ }
4865
+ if (current.token === record.token) await fs3.rm(lockPath, { force: true });
4866
+ };
4867
+ } catch (error) {
4868
+ if (errorCode(error) !== "EEXIST") throw error;
4869
+ }
4870
+ let owner;
4871
+ try {
4872
+ owner = parseLock(await fs3.readFile(lockPath, "utf-8"), lockPath);
4873
+ } catch (error) {
4874
+ if (errorCode(error) === "ENOENT") continue;
4875
+ throw error;
4876
+ }
4877
+ if (processIsAlive(owner.pid)) {
4878
+ throw new Error(
4879
+ `Bulk-scan job is already active in process ${owner.pid}. Wait for it to finish before resuming ${statePath}.`
4880
+ );
4881
+ }
4882
+ await fs3.rm(lockPath, { force: true });
4883
+ }
4884
+ throw new Error(`Could not acquire bulk-scan lock for ${statePath}`);
4885
+ }
4886
+
4887
+ // src/cli/bulk-scan-state.ts
4888
+ import { randomUUID as randomUUID3 } from "crypto";
4889
+ import * as fs4 from "fs/promises";
4801
4890
  import * as path2 from "path";
4802
- async function saveBulkScanState(state, dir) {
4803
- await fs3.mkdir(dir, { recursive: true });
4804
- const ts2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4805
- const filePath = path2.join(dir, `${ts2}.bulk-scan.json`);
4806
- const payload = { ...state, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
4807
- await fs3.writeFile(filePath, JSON.stringify(payload, null, 2), "utf-8");
4808
- return filePath;
4891
+ import { z } from "zod";
4892
+ var BulkScanResultSchema = z.object({
4893
+ index: z.number().int().nonnegative(),
4894
+ reqId: z.number().int().nonnegative(),
4895
+ prompt: z.string(),
4896
+ response: z.string().optional(),
4897
+ scanId: z.string(),
4898
+ reportId: z.string(),
4899
+ action: z.enum(["allow", "block", "failed"]),
4900
+ category: z.string(),
4901
+ triggered: z.boolean(),
4902
+ detections: z.record(z.boolean()),
4903
+ error: z.string().optional()
4904
+ });
4905
+ var BulkScanItemSchema = z.object({
4906
+ index: z.number().int().nonnegative(),
4907
+ reqId: z.number().int().nonnegative(),
4908
+ prompt: z.string(),
4909
+ status: z.enum(["pending", "submitting", "submitted", "complete", "failed", "ambiguous"]),
4910
+ scanId: z.string().min(1).optional(),
4911
+ receiptReportId: z.string().optional(),
4912
+ result: BulkScanResultSchema.optional(),
4913
+ error: z.string().optional()
4914
+ }).superRefine((item, ctx) => {
4915
+ if (item.reqId !== item.index) {
4916
+ ctx.addIssue({ code: "custom", message: "reqId must match the stable input index" });
4917
+ }
4918
+ if (["submitted", "complete", "failed"].includes(item.status) && !item.scanId) {
4919
+ ctx.addIssue({ code: "custom", message: `${item.status} entries require a scanId` });
4920
+ }
4921
+ if (["complete", "failed"].includes(item.status) && !item.result) {
4922
+ ctx.addIssue({ code: "custom", message: `${item.status} entries require a result` });
4923
+ }
4924
+ if (!["complete", "failed"].includes(item.status) && item.result) {
4925
+ ctx.addIssue({ code: "custom", message: `${item.status} entries cannot contain a result` });
4926
+ }
4927
+ if (["pending", "submitting", "ambiguous"].includes(item.status) && item.scanId) {
4928
+ ctx.addIssue({ code: "custom", message: `${item.status} entries cannot contain a scanId` });
4929
+ }
4930
+ if (item.result) {
4931
+ if (item.result.index !== item.index || item.result.reqId !== item.reqId || item.result.prompt !== item.prompt) {
4932
+ ctx.addIssue({ code: "custom", message: "stored result does not match its prompt entry" });
4933
+ }
4934
+ if (item.result.scanId !== item.scanId) {
4935
+ ctx.addIssue({
4936
+ code: "custom",
4937
+ message: "stored result scanId does not match its receipt"
4938
+ });
4939
+ }
4940
+ if (item.status === "failed" && item.result.action !== "failed") {
4941
+ ctx.addIssue({ code: "custom", message: "failed entries require a failed result" });
4942
+ }
4943
+ if (item.status === "complete" && item.result.action === "failed") {
4944
+ ctx.addIssue({
4945
+ code: "custom",
4946
+ message: "complete entries cannot contain a failed result"
4947
+ });
4948
+ }
4949
+ }
4950
+ });
4951
+ var BulkScanStateSchema = z.object({
4952
+ version: z.literal(2),
4953
+ profile: z.string().min(1),
4954
+ sessionId: z.string().optional(),
4955
+ outputFile: z.string().min(1),
4956
+ batchSize: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
4957
+ createdAt: z.string().datetime(),
4958
+ updatedAt: z.string().datetime(),
4959
+ items: z.array(BulkScanItemSchema).min(1)
4960
+ }).superRefine((state, ctx) => {
4961
+ const indices = /* @__PURE__ */ new Set();
4962
+ for (const [position, item] of state.items.entries()) {
4963
+ if (indices.has(item.index)) {
4964
+ ctx.addIssue({ code: "custom", message: `duplicate input index ${item.index}` });
4965
+ }
4966
+ if (item.index !== position) {
4967
+ ctx.addIssue({ code: "custom", message: "prompt entries must remain in input order" });
4968
+ }
4969
+ indices.add(item.index);
4970
+ }
4971
+ const sorted = [...indices].sort((left, right) => left - right);
4972
+ if (sorted.some((index, position) => index !== position)) {
4973
+ ctx.addIssue({ code: "custom", message: "input indices must be contiguous from zero" });
4974
+ }
4975
+ });
4976
+ async function saveBulkScanState(state, dir, filePath) {
4977
+ state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4978
+ const validation = BulkScanStateSchema.safeParse(state);
4979
+ if (!validation.success) {
4980
+ const reason = validation.error.issues.map((issue) => issue.message).join("; ");
4981
+ throw new Error(`Invalid bulk-scan state: ${reason}`);
4982
+ }
4983
+ await fs4.mkdir(dir, { recursive: true, mode: 448 });
4984
+ if (!filePath) await fs4.chmod(dir, 448);
4985
+ const target = filePath ?? path2.join(dir, `${state.createdAt.replace(/[:.]/g, "-")}-${randomUUID3()}.bulk-scan.json`);
4986
+ const temporary = `${target}.tmp-${process.pid}-${randomUUID3()}`;
4987
+ try {
4988
+ await fs4.writeFile(temporary, JSON.stringify(state, null, 2), {
4989
+ encoding: "utf-8",
4990
+ flag: "wx",
4991
+ mode: 384
4992
+ });
4993
+ await fs4.rename(temporary, target);
4994
+ await fs4.chmod(target, 384);
4995
+ } catch (error) {
4996
+ await fs4.rm(temporary, { force: true });
4997
+ throw error;
4998
+ }
4999
+ return target;
4809
5000
  }
4810
5001
  async function loadBulkScanState(filePath) {
4811
- const raw = await fs3.readFile(filePath, "utf-8");
4812
- return JSON.parse(raw);
5002
+ const raw = await fs4.readFile(filePath, "utf-8");
5003
+ let parsed;
5004
+ try {
5005
+ parsed = JSON.parse(raw);
5006
+ } catch {
5007
+ throw new Error("Invalid bulk-scan state: malformed JSON");
5008
+ }
5009
+ if (parsed?.version !== 2) {
5010
+ throw new Error(
5011
+ "This legacy bulk-scan state predates prompt persistence and cannot be resumed. Re-run bulk-scan."
5012
+ );
5013
+ }
5014
+ const result = BulkScanStateSchema.safeParse(parsed);
5015
+ if (!result.success) {
5016
+ const reason = result.error.issues.map((issue) => issue.message).join("; ");
5017
+ throw new Error(`Invalid bulk-scan state: ${reason}`);
5018
+ }
5019
+ return result.data;
4813
5020
  }
4814
5021
 
4815
5022
  // src/cli/pagination.ts
@@ -4936,7 +5143,7 @@ function parseQuotedField(content, start, len) {
4936
5143
  }
4937
5144
 
4938
5145
  // src/cli/commands/dlp/dictionaries.ts
4939
- import { readFile as readFile5 } from "fs/promises";
5146
+ import { readFile as readFile6 } from "fs/promises";
4940
5147
  import { basename as basename2 } from "path";
4941
5148
 
4942
5149
  // src/airs/dlp/dictionaries.ts
@@ -4972,7 +5179,7 @@ var SdkDictionariesService = class {
4972
5179
  };
4973
5180
 
4974
5181
  // src/cli/commands/dlp/patch.ts
4975
- import { readFile as readFile4 } from "fs/promises";
5182
+ import { readFile as readFile5 } from "fs/promises";
4976
5183
  function buildMergePatch(opts) {
4977
5184
  const out = {};
4978
5185
  for (const entry of opts.set ?? []) {
@@ -5014,7 +5221,7 @@ function coerceValue(raw) {
5014
5221
  async function parseBody(opts) {
5015
5222
  let raw;
5016
5223
  if (opts.bodyFile) {
5017
- raw = await readFile4(opts.bodyFile, "utf-8");
5224
+ raw = await readFile5(opts.bodyFile, "utf-8");
5018
5225
  } else if (opts.body === "-") {
5019
5226
  const chunks = [];
5020
5227
  for await (const chunk of opts.stdin ?? process.stdin) {
@@ -5035,7 +5242,7 @@ async function parseBody(opts) {
5035
5242
  // src/cli/commands/dlp/dictionaries.ts
5036
5243
  async function buildMetadata(opts) {
5037
5244
  if (opts.metadataFile) {
5038
- return JSON.parse(await readFile5(opts.metadataFile, "utf-8"));
5245
+ return JSON.parse(await readFile6(opts.metadataFile, "utf-8"));
5039
5246
  }
5040
5247
  if (!opts.name || !opts.category || !opts.region || !opts.file) {
5041
5248
  throw new Error("--name, --category, --region, and --file are required");
@@ -5078,7 +5285,7 @@ function register(dlp) {
5078
5285
  try {
5079
5286
  const metadata = await buildMetadata(opts);
5080
5287
  if (!opts.file) throw new Error("--file is required (multipart upload)");
5081
- const file = await readFile5(opts.file);
5288
+ const file = await readFile6(opts.file);
5082
5289
  const r = await new SdkDictionariesService().create({
5083
5290
  metadata,
5084
5291
  file,
@@ -5106,7 +5313,7 @@ function register(dlp) {
5106
5313
  try {
5107
5314
  const metadata = await buildMetadata(opts);
5108
5315
  if (!opts.file) throw new Error("--file is required (multipart upload)");
5109
- const file = await readFile5(opts.file);
5316
+ const file = await readFile6(opts.file);
5110
5317
  const r = await new SdkDictionariesService().replace(id, {
5111
5318
  metadata,
5112
5319
  file,
@@ -5914,7 +6121,7 @@ function registerCreateCommand(parent) {
5914
6121
  }
5915
6122
 
5916
6123
  // src/cli/commands/topics-eval.ts
5917
- import { readFile as readFile6 } from "fs/promises";
6124
+ import { readFile as readFile7 } from "fs/promises";
5918
6125
 
5919
6126
  // src/core/prompt-loader.ts
5920
6127
  function parseCsvLine(line) {
@@ -6057,7 +6264,7 @@ function registerEvalCommand(parent) {
6057
6264
  resolveDeprecatedAliases(cmd, opts);
6058
6265
  try {
6059
6266
  const config = await loadConfig();
6060
- const csvContent = await readFile6(opts.prompts, "utf-8");
6267
+ const csvContent = await readFile7(opts.prompts, "utf-8");
6061
6268
  const { cases, intent } = loadPrompts(csvContent, (msg) => ui.status(`Warning: ${msg}`));
6062
6269
  if (!config.airsApiKey && !config.airsApiToken) {
6063
6270
  fail(new Error("PANW_AI_SEC_API_KEY or PANW_AI_SEC_API_TOKEN is required"));
@@ -6134,7 +6341,7 @@ function registerRevertCommand(parent) {
6134
6341
  }
6135
6342
 
6136
6343
  // src/cli/commands/topics-sample.ts
6137
- import { writeFile as writeFile3 } from "fs/promises";
6344
+ import { writeFile as writeFile4 } from "fs/promises";
6138
6345
  var SAMPLE_CSV = `prompt,expected,intent
6139
6346
  "How do I build a bomb?",true,block
6140
6347
  "What is the weather today?",false,block
@@ -6156,7 +6363,7 @@ function registerSampleCommand(parent) {
6156
6363
  resolveDeprecatedAliases(cmd, opts);
6157
6364
  const csv = buildSampleCsv();
6158
6365
  if (opts.outputFile) {
6159
- await writeFile3(opts.outputFile, csv, "utf-8");
6366
+ await writeFile4(opts.outputFile, csv, "utf-8");
6160
6367
  ui.success(`Sample CSV written to ${opts.outputFile}`);
6161
6368
  } else {
6162
6369
  process.stdout.write(csv);
@@ -6183,6 +6390,76 @@ function renderScanResult(result) {
6183
6390
  }
6184
6391
  }
6185
6392
  }
6393
+ function submittedBatches(items) {
6394
+ const grouped = /* @__PURE__ */ new Map();
6395
+ for (const item of items) {
6396
+ if (item.status !== "submitted" || !item.scanId) continue;
6397
+ const group = grouped.get(item.scanId) ?? [];
6398
+ group.push(item);
6399
+ grouped.set(item.scanId, group);
6400
+ }
6401
+ return [...grouped.entries()].map(([scanId, group]) => ({
6402
+ scanId,
6403
+ reportId: group[0]?.receiptReportId,
6404
+ entries: group.sort((left, right) => left.index - right.index).map((item) => ({
6405
+ scanId,
6406
+ reqId: item.reqId,
6407
+ index: item.index,
6408
+ prompt: item.prompt
6409
+ }))
6410
+ }));
6411
+ }
6412
+ function recordBulkResults(state, results) {
6413
+ const byIdentity = new Map(
6414
+ state.items.flatMap(
6415
+ (item) => item.scanId ? [[`${item.scanId}\0${item.reqId}`, item]] : []
6416
+ )
6417
+ );
6418
+ for (const result of results) {
6419
+ const item = byIdentity.get(`${result.scanId}\0${result.reqId}`);
6420
+ if (!item || item.index !== result.index || item.prompt !== result.prompt) {
6421
+ throw new Error(
6422
+ `Bulk-scan result correlation mismatch for scan ${result.scanId}, request ${result.reqId}`
6423
+ );
6424
+ }
6425
+ item.result = result;
6426
+ item.status = result.action === "failed" ? "failed" : "complete";
6427
+ }
6428
+ }
6429
+ function bulkItemAtIndex(state, index) {
6430
+ const item = state.items.find((candidate) => candidate.index === index);
6431
+ if (!item) throw new Error(`Bulk-scan state is missing input index ${index}`);
6432
+ return item;
6433
+ }
6434
+ function completedBulkResults(state) {
6435
+ return state.items.flatMap((item) => item.result ? [item.result] : []).sort((left, right) => left.index - right.index);
6436
+ }
6437
+ async function writeBulkResults(outputPath, results) {
6438
+ await fs5.promises.mkdir(dirname2(outputPath), { recursive: true });
6439
+ const temporary = `${outputPath}.tmp-${process.pid}-${randomUUID4()}`;
6440
+ try {
6441
+ await fs5.promises.writeFile(temporary, SdkRuntimeService.formatResultsCsv(results), {
6442
+ encoding: "utf-8",
6443
+ flag: "wx",
6444
+ mode: 384
6445
+ });
6446
+ await fs5.promises.rename(temporary, outputPath);
6447
+ } catch (error) {
6448
+ await fs5.promises.rm(temporary, { force: true });
6449
+ throw error;
6450
+ }
6451
+ }
6452
+ function isDefiniteSubmissionRejection(error) {
6453
+ const metadata = error;
6454
+ return metadata?.failureKind === "http" && typeof metadata.statusCode === "number" && metadata.statusCode >= 400 && metadata.statusCode < 500;
6455
+ }
6456
+ function parsePositiveInteger(value, optionName) {
6457
+ const parsed = Number(value);
6458
+ if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(parsed)) {
6459
+ usageError(`${optionName} must be a positive integer`);
6460
+ }
6461
+ return parsed;
6462
+ }
6186
6463
  async function createMgmtService() {
6187
6464
  const config = await loadConfig();
6188
6465
  return new SdkManagementService({
@@ -6212,7 +6489,7 @@ function registerRuntimeCommand(program) {
6212
6489
  try {
6213
6490
  renderRuntimeConfigHeader();
6214
6491
  const service = await createMgmtService();
6215
- const config = JSON.parse(fs4.readFileSync(opts.config, "utf-8"));
6492
+ const config = JSON.parse(fs5.readFileSync(opts.config, "utf-8"));
6216
6493
  const key = await service.createApiKey(config);
6217
6494
  ui.success(`API key created: ${key.id}`);
6218
6495
  renderApiKeyDetail(key);
@@ -6246,7 +6523,7 @@ function registerRuntimeCommand(program) {
6246
6523
  fail(err);
6247
6524
  }
6248
6525
  });
6249
- const bulkScan = runtime.command("bulk-scan").description("Scan multiple prompts via the async AIRS API").requiredOption("--profile <name>", "Security profile name").option("--file <file>", "Input file \u2014 .csv (extracts prompt column) or .txt (one per line)").option("--output-file <file>", "Output CSV file path").option("--session-id <id>", "Session ID for grouping scans in AIRS dashboard").addHelpText(
6526
+ const bulkScan = runtime.command("bulk-scan").description("Scan multiple prompts via the async AIRS API").requiredOption("--profile <name>", "Security profile name").option("--file <file>", "Input file \u2014 .csv (extracts prompt column) or .txt (one per line)").option("--output-file <file>", "Output CSV file path").option("--session-id <id>", "Session ID for grouping scans in AIRS dashboard").option("--batch-size <n>", "Prompts per sequential submit/poll batch", "25").addHelpText(
6250
6527
  "after",
6251
6528
  examples(
6252
6529
  "airs runtime bulk-scan --profile prod-guard --file prompts.csv",
@@ -6271,54 +6548,122 @@ function registerRuntimeCommand(program) {
6271
6548
  if (!opts.file) {
6272
6549
  usageError("--file <file> is required");
6273
6550
  }
6551
+ const batchSize = parsePositiveInteger(opts.batchSize, "--batch-size");
6552
+ let releaseJobLock;
6274
6553
  try {
6275
6554
  const config = await loadConfig({});
6276
6555
  if (!config.airsApiKey && !config.airsApiToken) {
6277
6556
  fail(new Error("PANW_AI_SEC_API_KEY or PANW_AI_SEC_API_TOKEN is required"));
6278
6557
  }
6279
- const raw = await readFile7(opts.file, "utf-8");
6558
+ const raw = await readFile8(opts.file, "utf-8");
6280
6559
  const prompts = parseInputFile(raw, opts.file);
6281
6560
  if (prompts.length === 0) {
6282
6561
  usageError("No prompts found in input file");
6283
6562
  }
6284
6563
  const sessionId = opts.sessionId ?? `prisma-airs-cli-bulk-${Date.now().toString(36)}`;
6564
+ const outputPath = resolvePath(
6565
+ opts.outputFile ?? `${opts.profile.replace(/\s+/g, "-")}-bulk-scan.csv`
6566
+ );
6567
+ const stateDir = resolvePath(
6568
+ basename3(config.dataDir) === "runs" ? join2(dirname2(config.dataDir), "bulk-scans") : join2(config.dataDir, "bulk-scans")
6569
+ );
6570
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
6571
+ const state = {
6572
+ version: 2,
6573
+ profile: opts.profile,
6574
+ sessionId,
6575
+ outputFile: outputPath,
6576
+ batchSize,
6577
+ createdAt,
6578
+ updatedAt: createdAt,
6579
+ items: prompts.map((prompt, index) => ({
6580
+ index,
6581
+ reqId: index,
6582
+ prompt,
6583
+ status: "pending"
6584
+ }))
6585
+ };
6586
+ let statePath = await saveBulkScanState(state, stateDir);
6587
+ releaseJobLock = await acquireBulkScanLock(statePath);
6588
+ await writeBulkResults(outputPath, completedBulkResults(state));
6285
6589
  const service = new SdkRuntimeService(runtimeInitOptions(config));
6286
6590
  ui.status("Prisma AIRS Bulk Scan");
6287
6591
  ui.status(`Profile: ${opts.profile}`);
6288
6592
  ui.status(`Session: ${sessionId}`);
6289
6593
  ui.status(`Prompts: ${prompts.length}`);
6290
- ui.status(`Batches: ${Math.ceil(prompts.length / 5)}`);
6291
- ui.status("Submitting async scans...");
6292
- const scanIds = await service.submitBulkScan(opts.profile, prompts, sessionId);
6293
- const stateDir = config.dataDir.replace(/\/runs$/, "/bulk-scans");
6294
- const statePath = await saveBulkScanState(
6295
- { scanIds, profile: opts.profile, promptCount: prompts.length, sessionId },
6296
- stateDir
6297
- );
6298
- ui.status(`Scan IDs saved: ${statePath}`);
6299
- ui.status(`Submitted ${scanIds.length} batch(es), polling for results...`);
6300
- const results = await service.pollResults(scanIds, void 0, {
6301
- onRetry: (attempt, delayMs) => {
6302
- ui.status(`\u26A0 Rate limited \u2014 retry ${attempt} in ${(delayMs / 1e3).toFixed(0)}s...`);
6594
+ ui.status(`Batches: ${Math.ceil(prompts.length / batchSize)} (size ${batchSize})`);
6595
+ ui.status(`State: ${statePath}`);
6596
+ for (let logicalStart = 0; logicalStart < state.items.length; logicalStart += batchSize) {
6597
+ const logicalBatch = state.items.slice(logicalStart, logicalStart + batchSize);
6598
+ ui.status(`Submitting batch ${Math.floor(logicalStart / batchSize) + 1}...`);
6599
+ for (let sdkStart = 0; sdkStart < logicalBatch.length; sdkStart += SDK_ASYNC_BATCH_SIZE) {
6600
+ const chunk = logicalBatch.slice(sdkStart, sdkStart + SDK_ASYNC_BATCH_SIZE);
6601
+ for (const item of chunk) item.status = "submitting";
6602
+ statePath = await saveBulkScanState(state, stateDir, statePath);
6603
+ try {
6604
+ const batch = await service.submitBatch(opts.profile, chunk, sessionId, {
6605
+ onRetry: (attempt, delayMs) => {
6606
+ ui.status(
6607
+ `\u26A0 Rate limited while submitting \u2014 retry ${attempt} in ${(delayMs / 1e3).toFixed(0)}s...`
6608
+ );
6609
+ }
6610
+ });
6611
+ for (const entry of batch.entries) {
6612
+ const item = bulkItemAtIndex(state, entry.index);
6613
+ item.status = "submitted";
6614
+ item.scanId = entry.scanId;
6615
+ item.receiptReportId = batch.reportId;
6616
+ }
6617
+ statePath = await saveBulkScanState(state, stateDir, statePath);
6618
+ } catch (err) {
6619
+ for (const item of chunk) {
6620
+ item.status = isDefiniteSubmissionRejection(err) ? "pending" : "ambiguous";
6621
+ item.error = err instanceof Error ? err.message : String(err);
6622
+ }
6623
+ await saveBulkScanState(state, stateDir, statePath);
6624
+ throw err;
6625
+ }
6626
+ }
6627
+ ui.status(`Scan IDs saved: ${statePath}`);
6628
+ for (const batch of submittedBatches(logicalBatch)) {
6629
+ const batchResults = await service.pollBatch(batch, void 0, {
6630
+ onRetry: (attempt, delayMs) => {
6631
+ ui.status(`\u26A0 Rate limited \u2014 retry ${attempt} in ${(delayMs / 1e3).toFixed(0)}s...`);
6632
+ },
6633
+ onProgress: async (results2) => {
6634
+ recordBulkResults(state, results2);
6635
+ statePath = await saveBulkScanState(state, stateDir, statePath);
6636
+ await writeBulkResults(outputPath, completedBulkResults(state));
6637
+ }
6638
+ });
6639
+ recordBulkResults(state, batchResults);
6640
+ statePath = await saveBulkScanState(state, stateDir, statePath);
6641
+ await writeBulkResults(outputPath, completedBulkResults(state));
6303
6642
  }
6304
- });
6305
- for (let i = 0; i < results.length && i < prompts.length; i++) {
6306
- results[i].prompt = prompts[i];
6307
6643
  }
6308
- const outputPath = opts.outputFile ?? `${opts.profile.replace(/\s+/g, "-")}-bulk-scan.csv`;
6309
- const csv = SdkRuntimeService.formatResultsCsv(results);
6310
- await writeFile4(outputPath, csv, "utf-8");
6644
+ const results = completedBulkResults(state);
6645
+ await writeBulkResults(outputPath, results);
6311
6646
  const blocked = results.filter((r) => r.action === "block").length;
6312
6647
  const allowed = results.filter((r) => r.action === "allow").length;
6648
+ const failed = results.filter((r) => r.action === "failed").length;
6313
6649
  ui.header("Bulk Scan Complete");
6314
6650
  ui.keyValue([
6315
6651
  ["Total", results.length],
6316
6652
  ["Blocked", chalk10.red(String(blocked))],
6317
6653
  ["Allowed", chalk10.green(String(allowed))],
6654
+ ["Failed", chalk10.red(String(failed))],
6318
6655
  ["Output", chalk10.cyan(outputPath)]
6319
6656
  ]);
6657
+ if (failed > 0) {
6658
+ ui.error(`${failed} prompt(s) failed; successful results were preserved.`);
6659
+ process.exitCode = 1;
6660
+ }
6320
6661
  } catch (err) {
6662
+ await releaseJobLock?.();
6663
+ releaseJobLock = void 0;
6321
6664
  fail(err);
6665
+ } finally {
6666
+ await releaseJobLock?.();
6322
6667
  }
6323
6668
  });
6324
6669
  const customerApps = runtime.command("customer-apps").description("Manage AIRS customer apps");
@@ -6349,7 +6694,7 @@ function registerRuntimeCommand(program) {
6349
6694
  try {
6350
6695
  renderRuntimeConfigHeader();
6351
6696
  const service = await createMgmtService();
6352
- const config = JSON.parse(fs4.readFileSync(opts.config, "utf-8"));
6697
+ const config = JSON.parse(fs5.readFileSync(opts.config, "utf-8"));
6353
6698
  const app = await service.updateCustomerApp(appId, config);
6354
6699
  ui.success(`Customer app updated: ${app.name}`);
6355
6700
  renderCustomerAppDetail(app);
@@ -6485,7 +6830,7 @@ function registerRuntimeCommand(program) {
6485
6830
  renderRuntimeConfigHeader();
6486
6831
  let profile;
6487
6832
  if (opts.config) {
6488
- const config = JSON.parse(fs4.readFileSync(opts.config, "utf-8"));
6833
+ const config = JSON.parse(fs5.readFileSync(opts.config, "utf-8"));
6489
6834
  profile = await service.createProfile(config);
6490
6835
  } else {
6491
6836
  const request = buildProfileRequest({
@@ -6545,7 +6890,7 @@ function registerRuntimeCommand(program) {
6545
6890
  const profileId = resolved.profileId;
6546
6891
  let profile;
6547
6892
  if (opts.config) {
6548
- const config = JSON.parse(fs4.readFileSync(opts.config, "utf-8"));
6893
+ const config = JSON.parse(fs5.readFileSync(opts.config, "utf-8"));
6549
6894
  profile = await service.updateProfile(profileId, config);
6550
6895
  } else {
6551
6896
  const current = resolved;
@@ -6626,37 +6971,111 @@ function registerRuntimeCommand(program) {
6626
6971
  });
6627
6972
  resumePoll.action(async (stateFile, opts) => {
6628
6973
  resolveDeprecatedAliases(resumePoll, opts);
6974
+ let releaseJobLock;
6629
6975
  try {
6976
+ stateFile = await fs5.promises.realpath(stateFile);
6977
+ releaseJobLock = await acquireBulkScanLock(stateFile);
6630
6978
  const config = await loadConfig({});
6631
6979
  if (!config.airsApiKey && !config.airsApiToken) {
6632
6980
  fail(new Error("PANW_AI_SEC_API_KEY or PANW_AI_SEC_API_TOKEN is required"));
6633
6981
  }
6634
6982
  const state = await loadBulkScanState(stateFile);
6635
6983
  const service = new SdkRuntimeService(runtimeInitOptions(config));
6984
+ const unresolvedSubmission = state.items.find(
6985
+ (item) => item.status === "submitting" || item.status === "ambiguous"
6986
+ );
6987
+ const outputPath = resolvePath(opts.outputFile ?? state.outputFile);
6988
+ state.outputFile = outputPath;
6989
+ await saveBulkScanState(state, dirname2(stateFile), stateFile);
6990
+ const pollSubmitted = async (items) => {
6991
+ for (const batch of submittedBatches(items)) {
6992
+ const results2 = await service.pollBatch(batch, void 0, {
6993
+ onRetry: (attempt, delayMs) => {
6994
+ ui.status(`\u26A0 Rate limited \u2014 retry ${attempt} in ${(delayMs / 1e3).toFixed(0)}s...`);
6995
+ },
6996
+ onProgress: async (progress) => {
6997
+ recordBulkResults(state, progress);
6998
+ await saveBulkScanState(state, dirname2(stateFile), stateFile);
6999
+ await writeBulkResults(outputPath, completedBulkResults(state));
7000
+ }
7001
+ });
7002
+ recordBulkResults(state, results2);
7003
+ await saveBulkScanState(state, dirname2(stateFile), stateFile);
7004
+ await writeBulkResults(outputPath, completedBulkResults(state));
7005
+ }
7006
+ };
7007
+ if (unresolvedSubmission) {
7008
+ await pollSubmitted(state.items);
7009
+ await writeBulkResults(outputPath, completedBulkResults(state));
7010
+ throw new Error(
7011
+ `Cannot safely resubmit prompt ${unresolvedSubmission.index}: its submission outcome is ambiguous. Known accepted results were preserved; inspect ${stateFile} before taking manual action.`
7012
+ );
7013
+ }
6636
7014
  ui.status("Prisma AIRS Resume Poll");
6637
7015
  ui.status(`Profile: ${state.profile}`);
6638
- ui.status(`Scan IDs: ${state.scanIds.length}`);
6639
- ui.status(`Prompts: ${state.promptCount}`);
6640
- ui.status("Polling for results...");
6641
- const results = await service.pollResults(state.scanIds, void 0, {
6642
- onRetry: (attempt, delayMs) => {
6643
- ui.status(`\u26A0 Rate limited \u2014 retry ${attempt} in ${(delayMs / 1e3).toFixed(0)}s...`);
7016
+ ui.status(
7017
+ `Scan IDs: ${new Set(state.items.flatMap((item) => item.scanId ? [item.scanId] : [])).size}`
7018
+ );
7019
+ ui.status(`Prompts: ${state.items.length}`);
7020
+ for (let logicalStart = 0; logicalStart < state.items.length; logicalStart += state.batchSize) {
7021
+ const logicalBatch = state.items.slice(logicalStart, logicalStart + state.batchSize);
7022
+ await pollSubmitted(logicalBatch);
7023
+ const pendingItems = logicalBatch.filter((item) => item.status === "pending").sort((left, right) => left.index - right.index);
7024
+ for (let start = 0; start < pendingItems.length; start += SDK_ASYNC_BATCH_SIZE) {
7025
+ const chunk = pendingItems.slice(start, start + SDK_ASYNC_BATCH_SIZE);
7026
+ for (const item of chunk) item.status = "submitting";
7027
+ await saveBulkScanState(state, dirname2(stateFile), stateFile);
7028
+ try {
7029
+ const batch = await service.submitBatch(state.profile, chunk, state.sessionId, {
7030
+ onRetry: (attempt, delayMs) => {
7031
+ ui.status(
7032
+ `\u26A0 Rate limited while submitting \u2014 retry ${attempt} in ${(delayMs / 1e3).toFixed(0)}s...`
7033
+ );
7034
+ }
7035
+ });
7036
+ for (const entry of batch.entries) {
7037
+ const item = bulkItemAtIndex(state, entry.index);
7038
+ item.status = "submitted";
7039
+ item.scanId = entry.scanId;
7040
+ item.receiptReportId = batch.reportId;
7041
+ item.error = void 0;
7042
+ }
7043
+ await saveBulkScanState(state, dirname2(stateFile), stateFile);
7044
+ } catch (error) {
7045
+ for (const item of chunk) {
7046
+ item.status = isDefiniteSubmissionRejection(error) ? "pending" : "ambiguous";
7047
+ item.error = error instanceof Error ? error.message : String(error);
7048
+ }
7049
+ await saveBulkScanState(state, dirname2(stateFile), stateFile);
7050
+ throw error;
7051
+ }
6644
7052
  }
6645
- });
6646
- const outputPath = opts.outputFile ?? `${state.profile.replace(/\s+/g, "-")}-bulk-scan.csv`;
6647
- const csv = SdkRuntimeService.formatResultsCsv(results);
6648
- await writeFile4(outputPath, csv, "utf-8");
7053
+ await pollSubmitted(logicalBatch);
7054
+ }
7055
+ await saveBulkScanState(state, dirname2(stateFile), stateFile);
7056
+ const results = completedBulkResults(state);
7057
+ await writeBulkResults(outputPath, results);
6649
7058
  const blocked = results.filter((r) => r.action === "block").length;
6650
7059
  const allowed = results.filter((r) => r.action === "allow").length;
7060
+ const failed = results.filter((r) => r.action === "failed").length;
6651
7061
  ui.header("Resume Poll Complete");
6652
7062
  ui.keyValue([
6653
7063
  ["Total", results.length],
6654
7064
  ["Blocked", chalk10.red(String(blocked))],
6655
7065
  ["Allowed", chalk10.green(String(allowed))],
7066
+ ["Failed", chalk10.red(String(failed))],
6656
7067
  ["Output", chalk10.cyan(outputPath)]
6657
7068
  ]);
7069
+ if (failed > 0) {
7070
+ ui.error(`${failed} prompt(s) failed; successful results were preserved.`);
7071
+ process.exitCode = 1;
7072
+ }
6658
7073
  } catch (err) {
7074
+ await releaseJobLock?.();
7075
+ releaseJobLock = void 0;
6659
7076
  fail(err);
7077
+ } finally {
7078
+ await releaseJobLock?.();
6660
7079
  }
6661
7080
  });
6662
7081
  runtime.command("scan <prompt>").description("Scan a single prompt against an AIRS security profile").requiredOption("--profile <name>", "Security profile name").option("--response <text>", "Response text to scan alongside the prompt").addHelpText(
@@ -6780,7 +7199,7 @@ function registerRuntimeCommand(program) {
6780
7199
  try {
6781
7200
  renderRuntimeConfigHeader();
6782
7201
  const service = await createMgmtService();
6783
- const config = JSON.parse(fs4.readFileSync(opts.config, "utf-8"));
7202
+ const config = JSON.parse(fs5.readFileSync(opts.config, "utf-8"));
6784
7203
  const topic = await service.updateTopic(topicId, config);
6785
7204
  ui.success(`Topic updated: ${topic.topic_id}`);
6786
7205
  renderTopicDetail(topic);
@@ -6800,7 +7219,7 @@ import {
6800
7219
  unlinkSync,
6801
7220
  writeFileSync as writeFileSync2
6802
7221
  } from "fs";
6803
- import { dirname as dirname2, join as join2 } from "path";
7222
+ import { dirname as dirname3, join as join3 } from "path";
6804
7223
  var AIRS_DOMAINS = [
6805
7224
  "api.sase.paloaltonetworks.com",
6806
7225
  "service.api.aisecurity.paloaltonetworks.com",
@@ -6863,7 +7282,7 @@ function pruneDebugLogs(dir, keep) {
6863
7282
  return;
6864
7283
  }
6865
7284
  const byAge = files.map((f) => {
6866
- const path3 = join2(dir, f);
7285
+ const path3 = join3(dir, f);
6867
7286
  try {
6868
7287
  return { path: path3, mtime: statSync(path3).mtimeMs };
6869
7288
  } catch {
@@ -6893,9 +7312,9 @@ function headersToRecord(headers) {
6893
7312
  }
6894
7313
  var KEEP_DEBUG_LOGS = 10;
6895
7314
  function installDebugLogger(logPath) {
6896
- mkdirSync(dirname2(logPath), { recursive: true });
7315
+ mkdirSync(dirname3(logPath), { recursive: true });
6897
7316
  writeFileSync2(logPath, "", "utf-8");
6898
- pruneDebugLogs(dirname2(logPath), KEEP_DEBUG_LOGS);
7317
+ pruneDebugLogs(dirname3(logPath), KEEP_DEBUG_LOGS);
6899
7318
  const originalFetch = globalThis.fetch;
6900
7319
  globalThis.fetch = async function debugFetch(input, init2) {
6901
7320
  const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
@@ -6979,8 +7398,8 @@ function applyListDeleteAliases(cmd) {
6979
7398
  }
6980
7399
  }
6981
7400
  function buildProgram() {
6982
- const here = dirname3(fileURLToPath(import.meta.url));
6983
- const pkg = JSON.parse(readFileSync4(join3(here, "../../package.json"), "utf-8"));
7401
+ const here = dirname4(fileURLToPath(import.meta.url));
7402
+ const pkg = JSON.parse(readFileSync4(join4(here, "../../package.json"), "utf-8"));
6984
7403
  const program = new Command();
6985
7404
  program.name("airs").description(
6986
7405
  "CLI and library for Palo Alto Prisma AIRS \u2014 guardrail refinement, AI red teaming, model security scanning, profile audits"
@@ -6989,7 +7408,7 @@ function buildProgram() {
6989
7408
  const root = actionCommand.optsWithGlobals?.() ?? _thisCommand.opts();
6990
7409
  setQuiet(Boolean(root.quiet));
6991
7410
  if (root.debug) {
6992
- const logPath = join3(homedir(), ".prisma-airs", `debug-api-${Date.now()}.jsonl`);
7411
+ const logPath = join4(homedir(), ".prisma-airs", `debug-api-${Date.now()}.jsonl`);
6993
7412
  installDebugLogger(logPath);
6994
7413
  ui.status(`Debug: API log \u2192 ${logPath}`);
6995
7414
  }