@gendigital/sage-openclaw 0.13.1 → 0.15.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/index.cjs CHANGED
@@ -63,7 +63,7 @@ async function renameWithRetry(from, to, attempts = 5) {
63
63
  const transient = code === "EPERM" || code === "EACCES" || code === "EBUSY";
64
64
  if (!transient || i === attempts - 1)
65
65
  throw err;
66
- await new Promise((resolve10) => setTimeout(resolve10, 10 * 2 ** i));
66
+ await new Promise((resolve11) => setTimeout(resolve11, 10 * 2 ** i));
67
67
  }
68
68
  }
69
69
  }
@@ -4292,7 +4292,10 @@ var init_zod = __esm({
4292
4292
  });
4293
4293
 
4294
4294
  // ../core/dist/types.js
4295
- var nullLogger, ArtifactTypeSchema, ArtifactSchema, VerdictSeveritySchema, ThreatSchema, DecisionSchema, SensitivitySchema, UrlCheckConfigSchema, CacheConfigSchema, LoggingConfigSchema, OperationalLogLevelSchema, OperationalLoggingConfigSchema, FileCheckConfigSchema, PackageCheckConfigSchema, AmsiCheckConfigSchema, SkillCheckConfigSchema, DEFAULT_PI_HIGH_RISK_THRESHOLD, DEFAULT_PI_MEDIUM_RISK_THRESHOLD, DEFAULT_PI_TELEMETRY_THRESHOLD, PiCheckConfigSchema, ExceptionDecisionSchema, ExceptionMatchSchema, ExceptionRuleSchema, ExceptionsFileSchema, ExceptionsConfigSchema, ConfigSchema, HookTypeSchema;
4295
+ function isSubmitOnlyThreat(threat) {
4296
+ return threat.flags?.includes(SUBMIT_ONLY_FLAG) ?? false;
4297
+ }
4298
+ var nullLogger, ArtifactTypeSchema, ArtifactSchema, VerdictSeveritySchema, ThreatSchema, SUBMIT_ONLY_FLAG, DecisionSchema, SensitivitySchema, UrlCheckConfigSchema, CacheConfigSchema, LoggingConfigSchema, OperationalLogLevelSchema, OperationalLoggingConfigSchema, FileCheckConfigSchema, PackageCheckConfigSchema, AmsiCheckConfigSchema, SkillCheckConfigSchema, DEFAULT_PI_HIGH_RISK_THRESHOLD, DEFAULT_PI_MEDIUM_RISK_THRESHOLD, DEFAULT_PI_TELEMETRY_THRESHOLD, PiCheckConfigSchema, ExceptionDecisionSchema, ExceptionMatchSchema, ExceptionRuleSchema, ExceptionsFileSchema, ExceptionsConfigSchema, ConfigSchema, HookTypeSchema;
4296
4299
  var init_types2 = __esm({
4297
4300
  "../core/dist/types.js"() {
4298
4301
  "use strict";
@@ -4328,6 +4331,7 @@ var init_types2 = __esm({
4328
4331
  revoked: external_exports.boolean().optional().default(false),
4329
4332
  flags: external_exports.array(external_exports.string()).optional().default([])
4330
4333
  });
4334
+ SUBMIT_ONLY_FLAG = "submit_only";
4331
4335
  DecisionSchema = external_exports.enum(["allow", "deny", "ask"]);
4332
4336
  SensitivitySchema = external_exports.enum(["paranoid", "balanced", "relaxed"]);
4333
4337
  UrlCheckConfigSchema = external_exports.object({
@@ -6891,7 +6895,7 @@ async function ensurePiDeps(modelPath, logger = nullLogger) {
6891
6895
  }
6892
6896
  }
6893
6897
  function runNpm(cwd, args, logger) {
6894
- return new Promise((resolve10, reject) => {
6898
+ return new Promise((resolve11, reject) => {
6895
6899
  const isWindows = process.platform === "win32";
6896
6900
  const npmCmd = isWindows ? "npm.cmd" : "npm";
6897
6901
  const child = (0, import_node_child_process.execFile)(npmCmd, args, { cwd, timeout: 3e4, shell: isWindows }, (error, _stdout, stderr) => {
@@ -6899,7 +6903,7 @@ function runNpm(cwd, args, logger) {
6899
6903
  logger.warn(`npm install error: ${stderr || error.message}`);
6900
6904
  reject(error);
6901
6905
  } else {
6902
- resolve10();
6906
+ resolve11();
6903
6907
  }
6904
6908
  });
6905
6909
  child.unref();
@@ -14323,7 +14327,7 @@ var ApprovalStore = class {
14323
14327
  };
14324
14328
 
14325
14329
  // ../core/dist/audit-log.js
14326
- var import_node_crypto3 = require("node:crypto");
14330
+ var import_node_crypto4 = require("node:crypto");
14327
14331
  init_config();
14328
14332
 
14329
14333
  // ../core/dist/content-snapshot.js
@@ -14500,12 +14504,143 @@ function buildContentSnapshot(toolType, toolInput, artifacts = [], signals = {})
14500
14504
  return content;
14501
14505
  }
14502
14506
 
14507
+ // ../core/dist/detection-names.js
14508
+ var import_node_crypto3 = require("node:crypto");
14509
+
14510
+ // ../core/dist/model-storage.js
14511
+ var import_node_fs = require("node:fs");
14512
+ var import_node_path4 = require("node:path");
14513
+ init_config();
14514
+ var MODEL_SCHEMA_VERSION = "v2";
14515
+ var REQUIRED_MODELS_BY_SCHEMA = {
14516
+ v1: ["pi-model"],
14517
+ v2: ["pi-model"]
14518
+ };
14519
+ var REQUIRED_MODEL_FILES = {
14520
+ "pi-model": [
14521
+ "model_int8.onnx",
14522
+ "tokenizer.json",
14523
+ "tokenizer_config.json",
14524
+ "special_tokens_map.json",
14525
+ "vocab.txt",
14526
+ "config.json"
14527
+ ]
14528
+ };
14529
+ function getModelStorageRoot(sageDir = resolvePath("~/.sage")) {
14530
+ return (0, import_node_path4.join)(resolvePath(sageDir), "models");
14531
+ }
14532
+ function getModelDir(modelName, schema = MODEL_SCHEMA_VERSION, sageDir) {
14533
+ return (0, import_node_path4.join)(getModelStorageRoot(sageDir), schema, modelName);
14534
+ }
14535
+ function isModelPresent(modelName, schema = MODEL_SCHEMA_VERSION, sageDir) {
14536
+ const dir = getModelDir(modelName, schema, sageDir);
14537
+ const files = REQUIRED_MODEL_FILES[modelName];
14538
+ if (!files || files.length === 0)
14539
+ return false;
14540
+ return files.every((f) => (0, import_node_fs.existsSync)((0, import_node_path4.resolve)(dir, f)));
14541
+ }
14542
+ function missingRequiredModels(schema = MODEL_SCHEMA_VERSION, sageDir) {
14543
+ const required = REQUIRED_MODELS_BY_SCHEMA[schema] ?? [];
14544
+ return required.filter((name) => !isModelPresent(name, schema, sageDir));
14545
+ }
14546
+
14547
+ // ../core/dist/detection-names.js
14548
+ init_types2();
14549
+ var PI_DETECTION_NAME = "Other:SagePromptInjectionML-A [Susp]";
14550
+ var REPORTING_ENGINE_SHORTHANDS = {
14551
+ package: "sgpk",
14552
+ amsi: "sgam",
14553
+ heuristics: "sghe",
14554
+ ml: "sgml",
14555
+ skill: "skan"
14556
+ };
14557
+ var REPORTING_DETECTION_SUFFIX = "sage";
14558
+ function formatReportingDetectionName(canonicalName, engine, optionalData = "") {
14559
+ const engineMetadata = optionalData ? `${REPORTING_ENGINE_SHORTHANDS[engine]}:${optionalData}` : REPORTING_ENGINE_SHORTHANDS[engine];
14560
+ return `${canonicalName}|${engineMetadata}|${REPORTING_DETECTION_SUFFIX}`;
14561
+ }
14562
+ function sha256Hex(text) {
14563
+ return (0, import_node_crypto3.createHash)("sha256").update(text).digest("hex");
14564
+ }
14565
+ function formatOnlineClassifierDetectionName(postfix, modelVersion, textHash) {
14566
+ return `Text:OnlineClassifier [${postfix}]|${modelVersion}-${textHash}|sgpi`;
14567
+ }
14568
+ function piReportingDetectionName(r) {
14569
+ if (r.risk >= DEFAULT_PI_MEDIUM_RISK_THRESHOLD && r.modelVersion && r.contentSnippet) {
14570
+ return formatOnlineClassifierDetectionName("Misc", r.modelVersion, sha256Hex(r.contentSnippet));
14571
+ }
14572
+ return formatReportingDetectionName(PI_DETECTION_NAME, "ml", `${r.modelId}:${MODEL_SCHEMA_VERSION}`);
14573
+ }
14574
+ var PACKAGE_DETECTION_NAMES = {
14575
+ not_found: "Other:SagePackageNotFound-A [Susp]",
14576
+ suspicious_age: "Other:SagePackageNew-A [Susp]",
14577
+ malicious: "Other:SagePackageMalicious-A [Trj]",
14578
+ unknown: "Other:SagePackageUnknown-A [Susp]"
14579
+ };
14580
+ var AMSI_DETECTION_NAMES = {
14581
+ detected: "Other:SageAmsiDetected-A [Heur]",
14582
+ blocked_by_admin: "Other:SageAmsiBlockedByAdmin-A [Heur]",
14583
+ unknown: "Other:SageAmsiUnknown-A [Susp]"
14584
+ };
14585
+ var AMSI_REPORTING_RULE_NAMES = {
14586
+ detected: "AMSI_DETECTED",
14587
+ blocked_by_admin: "AMSI_BLOCKED_BY_ADMIN",
14588
+ unknown: "AMSI_UNKNOWN"
14589
+ };
14590
+ var SKILL_DETECTION_NAMES = {
14591
+ HIGH: "Other:SageSuspiciousSkill-A [Susp]",
14592
+ CRITICAL: "Other:SageMaliciousSkill-A [Trj]"
14593
+ };
14594
+ function isCanonicalDetectionName(name) {
14595
+ return /^\w+:\w+-[A-Z]+ \[[^\]\r\n]+\]$/.test(name);
14596
+ }
14597
+ function packageDetectionName(verdict) {
14598
+ return PACKAGE_DETECTION_NAMES[verdict];
14599
+ }
14600
+ function amsiDetectionClass(amsiResult) {
14601
+ if (amsiResult >= 32768)
14602
+ return "detected";
14603
+ if (amsiResult >= 16384)
14604
+ return "blocked_by_admin";
14605
+ return "unknown";
14606
+ }
14607
+ function amsiDetectionName(amsiResult) {
14608
+ return AMSI_DETECTION_NAMES[amsiDetectionClass(amsiResult)];
14609
+ }
14610
+ function amsiReportingData(amsiResult) {
14611
+ const ruleName = AMSI_REPORTING_RULE_NAMES[amsiDetectionClass(amsiResult)];
14612
+ return `${ruleName}:${Math.trunc(amsiResult).toString(16)}`;
14613
+ }
14614
+ function skillDetectionName(verdict) {
14615
+ return SKILL_DETECTION_NAMES[verdict.toUpperCase()];
14616
+ }
14617
+ function isSkillId(value) {
14618
+ return /^[0-9a-f]{64}$/i.test(value);
14619
+ }
14620
+ function skillReportingDetectionName(verdict, skillId) {
14621
+ const canonicalName = skillDetectionName(verdict);
14622
+ return canonicalName && isSkillId(skillId) ? formatReportingDetectionName(canonicalName, "skill", skillId.toLowerCase()) : void 0;
14623
+ }
14624
+ function buildSkillCheckSignal(skillId, analyzerVerdict, jobId) {
14625
+ const normalizedSkillId = skillId.toLowerCase();
14626
+ const normalizedVerdict = analyzerVerdict.toUpperCase();
14627
+ const detectionName = skillReportingDetectionName(normalizedVerdict, normalizedSkillId);
14628
+ if (!detectionName)
14629
+ return void 0;
14630
+ return {
14631
+ detection_name: detectionName,
14632
+ skill_id: normalizedSkillId,
14633
+ ...jobId ? { job_id: jobId } : {},
14634
+ analyzer_verdict: normalizedVerdict
14635
+ };
14636
+ }
14637
+
14503
14638
  // ../core/dist/audit-log.js
14504
14639
  init_file_utils();
14505
14640
 
14506
14641
  // ../core/dist/jsonl-log-writer.js
14507
14642
  var import_promises2 = require("node:fs/promises");
14508
- var import_node_path4 = require("node:path");
14643
+ var import_node_path5 = require("node:path");
14509
14644
  init_config();
14510
14645
  init_file_utils();
14511
14646
  var writeQueues = /* @__PURE__ */ new Map();
@@ -14542,7 +14677,7 @@ async function rotateIfNeeded(filePath, maxBytes, maxFiles) {
14542
14677
  });
14543
14678
  }
14544
14679
  async function appendJsonlEntryNow(path, config2, entry) {
14545
- await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(path), { recursive: true });
14680
+ await (0, import_promises2.mkdir)((0, import_node_path5.dirname)(path), { recursive: true });
14546
14681
  await rotateIfNeeded(path, config2.max_bytes, config2.max_files);
14547
14682
  await (0, import_promises2.appendFile)(path, `${JSON.stringify(entry)}
14548
14683
  `);
@@ -14590,7 +14725,7 @@ async function logVerdict(config2, input) {
14590
14725
  return;
14591
14726
  const entry = {
14592
14727
  type: "runtime_verdict",
14593
- entry_id: input.eventId ?? (0, import_node_crypto3.randomUUID)(),
14728
+ entry_id: input.eventId ?? (0, import_node_crypto4.randomUUID)(),
14594
14729
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
14595
14730
  session_id: input.sessionId,
14596
14731
  conversation_id: input.conversationId ?? input.sessionId,
@@ -14604,6 +14739,8 @@ async function logVerdict(config2, input) {
14604
14739
  reasons: input.verdict.reasons,
14605
14740
  source: input.verdict.source,
14606
14741
  threat_id: input.verdict.matchedThreatId,
14742
+ ...input.verdict.customThreatMatched ? { custom_threat_matched: true } : {},
14743
+ ...input.nonCustomThreatMatched ? { non_custom_detection_matched: true } : {},
14607
14744
  user_override: userOverride,
14608
14745
  signals: input.signals,
14609
14746
  tool_use_id: input.toolUseId
@@ -14651,6 +14788,37 @@ async function logSkillQueued(config2, skillId, pluginKey, skillFolder) {
14651
14788
  } catch {
14652
14789
  }
14653
14790
  }
14791
+ async function logSkillVerdict(config2, skillId, status, verdict, summary, context = {}) {
14792
+ if (!config2.enabled)
14793
+ return false;
14794
+ const entry = {
14795
+ type: "skill_verdict",
14796
+ entry_id: context.entryId ?? (0, import_node_crypto4.randomUUID)(),
14797
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
14798
+ skill_id: skillId,
14799
+ status
14800
+ };
14801
+ if (context.jobId !== void 0)
14802
+ entry.job_id = context.jobId;
14803
+ if (context.agentRuntime !== void 0)
14804
+ entry.agent_runtime = context.agentRuntime;
14805
+ if (verdict !== void 0)
14806
+ entry.verdict = verdict;
14807
+ if (summary !== void 0)
14808
+ entry.summary = summary.slice(0, MAX_SUMMARY_LEN);
14809
+ const skillSignal = status === "analyzed" && verdict !== void 0 ? buildSkillCheckSignal(skillId, verdict, context.jobId) : void 0;
14810
+ if (skillSignal) {
14811
+ entry.signals = {
14812
+ skill_checks: [skillSignal]
14813
+ };
14814
+ }
14815
+ try {
14816
+ await appendEntry(config2, entry);
14817
+ return true;
14818
+ } catch {
14819
+ return false;
14820
+ }
14821
+ }
14654
14822
 
14655
14823
  // ../core/dist/cache.js
14656
14824
  init_config();
@@ -15089,19 +15257,19 @@ var PersistentPowershellAmsiBackend = class {
15089
15257
  return this.available;
15090
15258
  }
15091
15259
  waitForLine(timeout) {
15092
- return new Promise((resolve10, reject) => {
15260
+ return new Promise((resolve11, reject) => {
15093
15261
  const timer = setTimeout(() => {
15094
15262
  this.pendingResponse = null;
15095
15263
  reject(new Error("timeout"));
15096
15264
  }, timeout);
15097
- this.pendingResponse = { resolve: resolve10, reject, timer };
15265
+ this.pendingResponse = { resolve: resolve11, reject, timer };
15098
15266
  });
15099
15267
  }
15100
15268
  async enqueueScan(operation) {
15101
15269
  const previous = this.scanQueue;
15102
15270
  let release;
15103
- this.scanQueue = new Promise((resolve10) => {
15104
- release = resolve10;
15271
+ this.scanQueue = new Promise((resolve11) => {
15272
+ release = resolve11;
15105
15273
  });
15106
15274
  await previous;
15107
15275
  try {
@@ -15149,10 +15317,10 @@ var PersistentPowershellAmsiBackend = class {
15149
15317
  const line = this.stdoutBuffer.slice(0, idx).trim();
15150
15318
  this.stdoutBuffer = this.stdoutBuffer.slice(idx + 1);
15151
15319
  if (this.pendingResponse) {
15152
- const { resolve: resolve10, timer } = this.pendingResponse;
15320
+ const { resolve: resolve11, timer } = this.pendingResponse;
15153
15321
  this.pendingResponse = null;
15154
15322
  clearTimeout(timer);
15155
- resolve10(line);
15323
+ resolve11(line);
15156
15324
  }
15157
15325
  idx = this.stdoutBuffer.indexOf("\n");
15158
15326
  }
@@ -15251,7 +15419,7 @@ var WslPowershellAmsiBackend = class {
15251
15419
  if (!this.available)
15252
15420
  return null;
15253
15421
  const truncated = content.length > MAX_SCAN_LENGTH ? content.slice(0, MAX_SCAN_LENGTH) : content;
15254
- return new Promise((resolve10) => {
15422
+ return new Promise((resolve11) => {
15255
15423
  try {
15256
15424
  const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PS_ONESHOT_SCRIPT], { stdio: ["pipe", "pipe", "pipe"] });
15257
15425
  let stdout = "";
@@ -15261,7 +15429,7 @@ var WslPowershellAmsiBackend = class {
15261
15429
  ps.kill();
15262
15430
  } catch {
15263
15431
  }
15264
- resolve10(null);
15432
+ resolve11(null);
15265
15433
  }, PS_TIMEOUT);
15266
15434
  ps.stdout?.on("data", (chunk) => {
15267
15435
  stdout += chunk.toString();
@@ -15274,7 +15442,7 @@ var WslPowershellAmsiBackend = class {
15274
15442
  ps.on("error", (err) => {
15275
15443
  clearTimeout(timer);
15276
15444
  this.logger.debug("AMSI: PowerShell one-shot error", { error: String(err) });
15277
- resolve10(null);
15445
+ resolve11(null);
15278
15446
  });
15279
15447
  ps.on("exit", () => {
15280
15448
  clearTimeout(timer);
@@ -15287,11 +15455,11 @@ var WslPowershellAmsiBackend = class {
15287
15455
  contentName
15288
15456
  });
15289
15457
  }
15290
- resolve10(null);
15458
+ resolve11(null);
15291
15459
  return;
15292
15460
  }
15293
15461
  this.logger.debug("AMSI: PowerShell one-shot result", { contentName, amsiResult });
15294
- resolve10(interpretAmsiResult(amsiResult, content, contentName));
15462
+ resolve11(interpretAmsiResult(amsiResult, content, contentName));
15295
15463
  });
15296
15464
  const req = JSON.stringify({ content: truncated, contentName });
15297
15465
  ps.stdin?.write(`${req}
@@ -15299,7 +15467,7 @@ var WslPowershellAmsiBackend = class {
15299
15467
  ps.stdin?.end();
15300
15468
  } catch (e) {
15301
15469
  this.logger.warn("AMSI: PowerShell one-shot failed", { error: String(e), contentName });
15302
- resolve10(null);
15470
+ resolve11(null);
15303
15471
  }
15304
15472
  });
15305
15473
  }
@@ -15439,15 +15607,15 @@ var ContentFetchClient = class _ContentFetchClient {
15439
15607
  init_types2();
15440
15608
 
15441
15609
  // ../core/dist/version.js
15442
- var import_node_path5 = require("node:path");
15610
+ var import_node_path6 = require("node:path");
15443
15611
  var import_node_url = require("node:url");
15444
15612
  init_file_utils();
15445
15613
  var import_meta = {};
15446
15614
  function resolveVersion() {
15447
15615
  if (true)
15448
- return "0.13.0";
15616
+ return "0.15.0";
15449
15617
  try {
15450
- const pkgPath = (0, import_node_path5.join)((0, import_node_path5.dirname)((0, import_node_url.fileURLToPath)(import_meta.url)), "..", "package.json");
15618
+ const pkgPath = (0, import_node_path6.join)((0, import_node_path6.dirname)((0, import_node_url.fileURLToPath)(import_meta.url)), "..", "package.json");
15451
15619
  const pkg = JSON.parse(getFileContentSync(pkgPath));
15452
15620
  if (typeof pkg.version === "string")
15453
15621
  return pkg.version;
@@ -15590,43 +15758,6 @@ var FileCheckClient = class {
15590
15758
  }
15591
15759
  };
15592
15760
 
15593
- // ../core/dist/model-storage.js
15594
- var import_node_fs = require("node:fs");
15595
- var import_node_path6 = require("node:path");
15596
- init_config();
15597
- var MODEL_SCHEMA_VERSION = "v2";
15598
- var REQUIRED_MODELS_BY_SCHEMA = {
15599
- v1: ["pi-model"],
15600
- v2: ["pi-model"]
15601
- };
15602
- var REQUIRED_MODEL_FILES = {
15603
- "pi-model": [
15604
- "model_int8.onnx",
15605
- "tokenizer.json",
15606
- "tokenizer_config.json",
15607
- "special_tokens_map.json",
15608
- "vocab.txt",
15609
- "config.json"
15610
- ]
15611
- };
15612
- function getModelStorageRoot(sageDir = resolvePath("~/.sage")) {
15613
- return (0, import_node_path6.join)(resolvePath(sageDir), "models");
15614
- }
15615
- function getModelDir(modelName, schema = MODEL_SCHEMA_VERSION, sageDir) {
15616
- return (0, import_node_path6.join)(getModelStorageRoot(sageDir), schema, modelName);
15617
- }
15618
- function isModelPresent(modelName, schema = MODEL_SCHEMA_VERSION, sageDir) {
15619
- const dir = getModelDir(modelName, schema, sageDir);
15620
- const files = REQUIRED_MODEL_FILES[modelName];
15621
- if (!files || files.length === 0)
15622
- return false;
15623
- return files.every((f) => (0, import_node_fs.existsSync)((0, import_node_path6.resolve)(dir, f)));
15624
- }
15625
- function missingRequiredModels(schema = MODEL_SCHEMA_VERSION, sageDir) {
15626
- const required = REQUIRED_MODELS_BY_SCHEMA[schema] ?? [];
15627
- return required.filter((name) => !isModelPresent(name, schema, sageDir));
15628
- }
15629
-
15630
15761
  // ../core/dist/clients/model-downloader.js
15631
15762
  init_types2();
15632
15763
  var STALE_LOCK_MS = 60 * 60 * 1e3;
@@ -15967,6 +16098,8 @@ var RegistryClient = class {
15967
16098
  };
15968
16099
 
15969
16100
  // ../core/dist/clients/pi-check.js
16101
+ var import_node_crypto5 = require("node:crypto");
16102
+ var import_promises3 = require("node:fs/promises");
15970
16103
  var import_node_path9 = require("node:path");
15971
16104
  init_types2();
15972
16105
  var DEFAULT_MAX_CONTENT_LENGTH2 = 16384;
@@ -15981,6 +16114,18 @@ var MODEL_METADATA_FILES = [
15981
16114
  "vocab.txt",
15982
16115
  MODEL_FILE
15983
16116
  ];
16117
+ async function computeModelVersion(modelPath, logger = nullLogger) {
16118
+ try {
16119
+ const hash = (0, import_node_crypto5.createHash)("sha256");
16120
+ for (const name of MODEL_METADATA_FILES) {
16121
+ hash.update(await (0, import_promises3.readFile)((0, import_node_path9.resolve)(modelPath, name)));
16122
+ }
16123
+ return hash.digest("hex").slice(0, 8);
16124
+ } catch (err) {
16125
+ logger.warn(`PI model version unavailable; hits keep the model-id detection name: ${err instanceof Error ? err.message : String(err)}`);
16126
+ return void 0;
16127
+ }
16128
+ }
15984
16129
  function softmax(logits) {
15985
16130
  const max = Math.max(...logits);
15986
16131
  const exp = logits.map((x) => Math.exp(x - max));
@@ -16005,6 +16150,7 @@ var BundledPiProvider = class _BundledPiProvider {
16005
16150
  logger;
16006
16151
  tokenizer = null;
16007
16152
  session = null;
16153
+ modelVersion;
16008
16154
  tensorClass = null;
16009
16155
  inputNames = /* @__PURE__ */ new Set();
16010
16156
  initPromise = null;
@@ -16070,6 +16216,7 @@ var BundledPiProvider = class _BundledPiProvider {
16070
16216
  findings,
16071
16217
  contentName: context,
16072
16218
  modelId,
16219
+ modelVersion: this.modelVersion,
16073
16220
  contentSnippet: maxChunk || void 0
16074
16221
  };
16075
16222
  this.logger.info("PI model inference completed", {
@@ -16112,6 +16259,7 @@ var BundledPiProvider = class _BundledPiProvider {
16112
16259
  this.session = await runtime.InferenceSession.create(modelFilePath);
16113
16260
  const sess = this.session;
16114
16261
  this.inputNames = new Set(sess.inputNames);
16262
+ this.modelVersion = await computeModelVersion(this.modelPath, this.logger);
16115
16263
  _BundledPiProvider.modelRuntimeLoaded = true;
16116
16264
  this.logger.info("PI model loaded", { path: this.modelPath });
16117
16265
  return true;
@@ -16303,6 +16451,7 @@ var SkillCheckClient = class {
16303
16451
  parseResult(skillId, raw) {
16304
16452
  return {
16305
16453
  skillId,
16454
+ jobId: typeof raw.job_id === "string" ? raw.job_id : void 0,
16306
16455
  verdict: typeof raw.verdict === "string" ? raw.verdict : void 0,
16307
16456
  summary: typeof raw.summary === "string" ? raw.summary : void 0
16308
16457
  };
@@ -16313,7 +16462,7 @@ var SkillCheckClient = class {
16313
16462
  init_config();
16314
16463
 
16315
16464
  // ../core/dist/config-defaults.js
16316
- var import_promises3 = require("node:fs/promises");
16465
+ var import_promises4 = require("node:fs/promises");
16317
16466
  var import_node_path10 = require("node:path");
16318
16467
  init_file_utils();
16319
16468
  init_types2();
@@ -16344,11 +16493,11 @@ async function deployConfigDefaults(sageDirPath, logger = nullLogger) {
16344
16493
  const targetPath = (0, import_node_path10.join)(sageDirPath, CONFIG_DEFAULTS_FILENAME);
16345
16494
  const defaults = serializeConfigDefaults();
16346
16495
  try {
16347
- await (0, import_promises3.mkdir)(sageDirPath, { recursive: true, mode: 448 });
16348
- await (0, import_promises3.chmod)(sageDirPath, 448);
16496
+ await (0, import_promises4.mkdir)(sageDirPath, { recursive: true, mode: 448 });
16497
+ await (0, import_promises4.chmod)(sageDirPath, 448);
16349
16498
  let existing = null;
16350
16499
  try {
16351
- existing = await (0, import_promises3.readFile)(targetPath, "utf-8");
16500
+ existing = await (0, import_promises4.readFile)(targetPath, "utf-8");
16352
16501
  } catch {
16353
16502
  }
16354
16503
  if (existing) {
@@ -16365,7 +16514,7 @@ async function deployConfigDefaults(sageDirPath, logger = nullLogger) {
16365
16514
  return;
16366
16515
  }
16367
16516
  await withFileLock(targetPath, async () => {
16368
- await (0, import_promises3.writeFile)(targetPath, defaults, { encoding: "utf-8", mode: 384 });
16517
+ await (0, import_promises4.writeFile)(targetPath, defaults, { encoding: "utf-8", mode: 384 });
16369
16518
  logger.debug("Deployed config defaults", {
16370
16519
  path: targetPath,
16371
16520
  schemaVersion: CONFIG_DEFAULTS_SCHEMA_VERSION
@@ -16512,58 +16661,8 @@ function isScannableContent(content) {
16512
16661
  return SNIFF_HTML.test(head) || SNIFF_HTML_TAGS.test(head) || SNIFF_JSON.test(head) || SNIFF_XML.test(head) || SNIFF_YAML.test(head) || SNIFF_SHEBANG_SHELL.test(head) || SNIFF_SHEBANG_PYTHON.test(head) || SNIFF_PYTHON.test(head) || SNIFF_JS_TS.test(head) || SNIFF_C_CPP.test(head) || SNIFF_KOTLIN.test(head) || SNIFF_CSV.test(head);
16513
16662
  }
16514
16663
 
16515
- // ../core/dist/detection-names.js
16516
- var PI_DETECTION_NAME = "Other:SagePromptInjectionML-A [Susp]";
16517
- var REPORTING_ENGINE_SHORTHANDS = {
16518
- package: "sgpk",
16519
- amsi: "sgam",
16520
- heuristics: "sghe",
16521
- ml: "sgml"
16522
- };
16523
- var REPORTING_DETECTION_SUFFIX = "sage";
16524
- function formatReportingDetectionName(canonicalName, engine, optionalData = "") {
16525
- const engineMetadata = optionalData ? `${REPORTING_ENGINE_SHORTHANDS[engine]}:${optionalData}` : REPORTING_ENGINE_SHORTHANDS[engine];
16526
- return `${canonicalName}|${engineMetadata}|${REPORTING_DETECTION_SUFFIX}`;
16527
- }
16528
- var PACKAGE_DETECTION_NAMES = {
16529
- not_found: "Other:SagePackageNotFound-A [Susp]",
16530
- suspicious_age: "Other:SagePackageNew-A [Susp]",
16531
- malicious: "Other:SagePackageMalicious-A [Trj]",
16532
- unknown: "Other:SagePackageUnknown-A [Susp]"
16533
- };
16534
- var AMSI_DETECTION_NAMES = {
16535
- detected: "Other:SageAmsiDetected-A [Heur]",
16536
- blocked_by_admin: "Other:SageAmsiBlockedByAdmin-A [Heur]",
16537
- unknown: "Other:SageAmsiUnknown-A [Susp]"
16538
- };
16539
- var AMSI_REPORTING_RULE_NAMES = {
16540
- detected: "AMSI_DETECTED",
16541
- blocked_by_admin: "AMSI_BLOCKED_BY_ADMIN",
16542
- unknown: "AMSI_UNKNOWN"
16543
- };
16544
- function isCanonicalDetectionName(name) {
16545
- return /^\w+:\w+-[A-Z]+ \[[^\]\r\n]+\]$/.test(name);
16546
- }
16547
- function packageDetectionName(verdict) {
16548
- return PACKAGE_DETECTION_NAMES[verdict];
16549
- }
16550
- function amsiDetectionClass(amsiResult) {
16551
- if (amsiResult >= 32768)
16552
- return "detected";
16553
- if (amsiResult >= 16384)
16554
- return "blocked_by_admin";
16555
- return "unknown";
16556
- }
16557
- function amsiDetectionName(amsiResult) {
16558
- return AMSI_DETECTION_NAMES[amsiDetectionClass(amsiResult)];
16559
- }
16560
- function amsiReportingData(amsiResult) {
16561
- const ruleName = AMSI_REPORTING_RULE_NAMES[amsiDetectionClass(amsiResult)];
16562
- return `${ruleName}:${Math.trunc(amsiResult).toString(16)}`;
16563
- }
16564
-
16565
16664
  // ../core/dist/extended-info.js
16566
- var import_promises4 = require("node:fs/promises");
16665
+ var import_promises5 = require("node:fs/promises");
16567
16666
  var import_node_os3 = require("node:os");
16568
16667
  var import_node_path11 = require("node:path");
16569
16668
  init_file_utils();
@@ -16643,7 +16742,7 @@ async function loadExtendedInfo(sageDirPath, logger = nullLogger) {
16643
16742
  async function loadExtendedInfoUncached(filePath, logger) {
16644
16743
  let size;
16645
16744
  try {
16646
- const info = await (0, import_promises4.stat)(filePath);
16745
+ const info = await (0, import_promises5.stat)(filePath);
16647
16746
  if (!info.isFile()) {
16648
16747
  logger.debug(`extended-info: not a regular file at ${filePath}`);
16649
16748
  return null;
@@ -16730,8 +16829,8 @@ function mergeExtendedInfo(envelope, extendedInfo) {
16730
16829
  }
16731
16830
 
16732
16831
  // ../core/dist/installation-id.js
16733
- var import_node_crypto4 = require("node:crypto");
16734
- var import_promises5 = require("node:fs/promises");
16832
+ var import_node_crypto6 = require("node:crypto");
16833
+ var import_promises6 = require("node:fs/promises");
16735
16834
  var import_node_path12 = require("node:path");
16736
16835
  init_config();
16737
16836
  init_file_utils();
@@ -16748,9 +16847,9 @@ async function getInstallationId(sageDirPath) {
16748
16847
  } catch {
16749
16848
  }
16750
16849
  try {
16751
- const id = (0, import_node_crypto4.randomUUID)();
16752
- await (0, import_promises5.mkdir)(sageDir, { recursive: true, mode: 448 });
16753
- await (0, import_promises5.writeFile)(idPath, id, { encoding: "utf-8", mode: 384, flag: fileExists ? "w" : "wx" });
16850
+ const id = (0, import_node_crypto6.randomUUID)();
16851
+ await (0, import_promises6.mkdir)(sageDir, { recursive: true, mode: 448 });
16852
+ await (0, import_promises6.writeFile)(idPath, id, { encoding: "utf-8", mode: 384, flag: fileExists ? "w" : "wx" });
16754
16853
  return id;
16755
16854
  } catch (err) {
16756
16855
  if (err.code === "EEXIST") {
@@ -16934,6 +17033,10 @@ var DecisionEngine = class {
16934
17033
  this.askThreshold = policy.askThreshold;
16935
17034
  this.logger = logger;
16936
17035
  }
17036
+ /** Whether these sources contain a decision signal independent of custom rules. */
17037
+ hasNonCustomDetection(sources) {
17038
+ return this.collectSignals(sources.heuristicMatches, sources.urlCheckResults, sources.packageCheckResults, sources.amsiCheckResults, sources.piCheckResults, sources.piThresholds).some((signal) => signal.customThreat !== true);
17039
+ }
16937
17040
  async decide(sources) {
16938
17041
  const signals = this.collectSignals(sources.heuristicMatches, sources.urlCheckResults, sources.packageCheckResults, sources.amsiCheckResults, sources.piCheckResults, sources.piThresholds);
16939
17042
  if (signals.length === 0) {
@@ -16952,12 +17055,15 @@ var DecisionEngine = class {
16952
17055
  source: top.source,
16953
17056
  artifacts: allArtifacts,
16954
17057
  matchedThreatId: top.threatId,
17058
+ customThreat: top.customThreat,
16955
17059
  reasons: allReasons
16956
17060
  };
16957
17061
  }
16958
17062
  collectSignals(heuristicMatches, urlCheckResults, packageCheckResults, amsiCheckResults, piCheckResults, piThresholds) {
16959
17063
  const signals = [];
16960
17064
  for (const match of heuristicMatches) {
17065
+ if (isSubmitOnlyThreat(match.threat))
17066
+ continue;
16961
17067
  const { confidence, category, severity, id, title } = match.threat;
16962
17068
  if (applyPolicy(confidence, this.denyThreshold, this.askThreshold, this.logger) === "allow")
16963
17069
  continue;
@@ -16967,6 +17073,7 @@ var DecisionEngine = class {
16967
17073
  severity,
16968
17074
  source: "heuristic",
16969
17075
  threatId: id,
17076
+ customThreat: match.threat.customRule === true,
16970
17077
  reason: title,
16971
17078
  artifact: match.artifact
16972
17079
  });
@@ -17092,43 +17199,422 @@ var DecisionEngine = class {
17092
17199
  };
17093
17200
 
17094
17201
  // ../core/dist/evaluator.js
17095
- var import_node_crypto6 = require("node:crypto");
17096
- init_config();
17097
-
17098
- // ../core/dist/exceptions.js
17099
- var import_node_crypto5 = require("node:crypto");
17100
- var import_node_path15 = require("node:path");
17202
+ var import_node_crypto8 = require("node:crypto");
17101
17203
  init_config();
17102
- init_file_utils();
17103
17204
 
17104
- // ../core/dist/trusted-domains.js
17105
- var import_promises6 = require("node:fs/promises");
17106
- var import_node_path14 = require("node:path");
17107
- var import_yaml = __toESM(require_dist(), 1);
17108
- init_file_utils();
17109
- init_types2();
17110
- async function loadTrustedDomains(trustedDomainsDir, logger = nullLogger) {
17111
- let files;
17112
- try {
17113
- files = (await (0, import_promises6.readdir)(trustedDomainsDir)).filter((f) => f.endsWith(".yaml")).sort();
17114
- } catch {
17115
- logger.debug("Trusted domains directory does not exist", { path: trustedDomainsDir });
17116
- return [];
17117
- }
17118
- const domains = [];
17119
- for (const filename of files) {
17120
- const filePath = (0, import_node_path14.join)(trustedDomainsDir, filename);
17121
- let content;
17122
- try {
17123
- content = await getFileContent(filePath);
17124
- } catch (e) {
17125
- logger.warn(`Failed to read ${filename}`, { error: String(e) });
17126
- continue;
17127
- }
17128
- let data;
17129
- try {
17130
- data = (0, import_yaml.parse)(content);
17131
- } catch (e) {
17205
+ // ../core/dist/extractors.js
17206
+ var URL_PATTERN = /https?:\/\/[^\s"')<>[\]{}]+/g;
17207
+ var TRAILING_PUNCT = /[.,;:!?]+$/;
17208
+ var MAX_CONTENT_SIZE = 64 * 1024;
17209
+ var BASE64_DECODE_EXEC = /\b(echo|printf|cat)\b[^|]*\|\s*base64\s+(-d|--decode)[^|]*\|\s*(bash|sh|zsh|ksh|dash|python|perl|ruby|node)\b/;
17210
+ var PRINTF_ENCODE_EXEC = /\bprintf\b\s+['"](\\x[0-9a-fA-F]{2}|\\[0-7]{3})[^|]*\|\s*(bash|sh|zsh|ksh|dash)\b/;
17211
+ var VAR_INTERPOLATION_URL = /\b(curl|wget|fetch)\b[^|;]*(\$\{?\w+\}?\.\$\{?\w+\}?|\$\{?\w+\}?:\/\/)/;
17212
+ var SHELLS = "bash|sh|zsh|ksh|dash";
17213
+ function extractUrls(text) {
17214
+ const seen = /* @__PURE__ */ new Set();
17215
+ const urls = [];
17216
+ for (const match of text.matchAll(URL_PATTERN)) {
17217
+ const url = match[0].replace(TRAILING_PUNCT, "");
17218
+ if (!seen.has(url)) {
17219
+ seen.add(url);
17220
+ urls.push(url);
17221
+ }
17222
+ }
17223
+ return urls;
17224
+ }
17225
+ function escapeRegExp(str) {
17226
+ const regExpEscape = RegExp.escape;
17227
+ if (regExpEscape) {
17228
+ return regExpEscape(str);
17229
+ }
17230
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17231
+ }
17232
+ function stripHeredocs(command) {
17233
+ return command.replace(/<<-?\s*['"]?(\w+)['"]?\n[\s\S]*?\n[ \t]*\1\b/g, "<<$1");
17234
+ }
17235
+ function extractFromBash(command) {
17236
+ const artifacts = [];
17237
+ const effective = stripHeredocs(command);
17238
+ artifacts.push({ type: "command", value: effective });
17239
+ if (BASE64_DECODE_EXEC.test(effective)) {
17240
+ artifacts.push({ type: "command", value: effective, context: "base64_decode_exec" });
17241
+ }
17242
+ if (PRINTF_ENCODE_EXEC.test(effective)) {
17243
+ artifacts.push({ type: "command", value: effective, context: "printf_encode_exec" });
17244
+ }
17245
+ if (VAR_INTERPOLATION_URL.test(effective)) {
17246
+ artifacts.push({ type: "command", value: effective, context: "variable_interpolation_url" });
17247
+ }
17248
+ for (const url of extractUrls(command)) {
17249
+ let context;
17250
+ const escaped = escapeRegExp(url);
17251
+ const directPipe = new RegExp(escaped + String.raw`[^|]*\|\s*(${SHELLS})\b`);
17252
+ const wrappedPipe = new RegExp(escaped + String.raw`[^|]*\|\s*\S+\s+(\S+\s+)?(${SHELLS})\b`);
17253
+ const subshellExec = new RegExp(`${String.raw`(\$\(|`}\`)${escaped}[^)\`]*${String.raw`\|\s*(${SHELLS})\b`}`);
17254
+ if (directPipe.test(command) || wrappedPipe.test(command) || subshellExec.test(command)) {
17255
+ context = "piped to shell";
17256
+ }
17257
+ artifacts.push({ type: "url", value: url, context });
17258
+ }
17259
+ return artifacts;
17260
+ }
17261
+ function extractFromWebFetch(toolInput) {
17262
+ const url = toolInput.url;
17263
+ if (url && typeof url === "string") {
17264
+ return [{ type: "url", value: url, context: "webfetch" }];
17265
+ }
17266
+ return [];
17267
+ }
17268
+ function extractFileArtifacts(toolInput, filePathKey, contentKey, contextName, urlContextName) {
17269
+ const artifacts = [];
17270
+ const filePath = toolInput[filePathKey];
17271
+ if (filePath && typeof filePath === "string") {
17272
+ artifacts.push({ type: "file_path", value: filePath, context: contextName });
17273
+ }
17274
+ const content = toolInput[contentKey];
17275
+ if (content && typeof content === "string" && content.trim()) {
17276
+ const capped = content.slice(0, MAX_CONTENT_SIZE);
17277
+ artifacts.push({ type: "content", value: capped, context: contextName });
17278
+ for (const url of extractUrls(capped)) {
17279
+ artifacts.push({ type: "url", value: url, context: urlContextName });
17280
+ }
17281
+ }
17282
+ return artifacts;
17283
+ }
17284
+ function extractFromWrite(toolInput) {
17285
+ return extractFileArtifacts(toolInput, "file_path", "content", "write", "from_file_content");
17286
+ }
17287
+ function extractFromEdit(toolInput) {
17288
+ return extractFileArtifacts(toolInput, "file_path", "new_string", "edit", "from_edit_content");
17289
+ }
17290
+
17291
+ // ../core/dist/deobfuscate.js
17292
+ var DEFAULT_DEOBFUSCATION_LIMITS = {
17293
+ maxInputLength: MAX_CONTENT_SIZE,
17294
+ maxVariants: 8,
17295
+ timeBudgetMs: 50,
17296
+ maxDepth: 2,
17297
+ maxDecodedBytes: MAX_CONTENT_SIZE
17298
+ };
17299
+ var INVISIBLE_CHARS = /[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g;
17300
+ function foldUnicode(text) {
17301
+ return text.replace(INVISIBLE_CHARS, "").normalize("NFKC");
17302
+ }
17303
+ var SPACED_RUN = /(?<![A-Za-z0-9@$])[A-Za-z0-9@$](?:[ \t]{1,4}[A-Za-z0-9@$]){5,}(?![A-Za-z0-9@$])/g;
17304
+ var SPACED_ELEMENT = /([A-Za-z0-9@$])([ \t]*)/g;
17305
+ var SPACED_MIN_LETTERS = 3;
17306
+ var INSTRUCTION_WORDS = `a an and any all are as at be by do for from i if in is it me my no not now of on or so the to up we
17307
+ you your this that these those what which who with without only just then than into out over
17308
+ above below before after earlier prior previous following new old other another again always never
17309
+ ignore ignoring disregard forget override overrule bypass skip stop obey comply follow execute exec
17310
+ run act acting pretend become reveal show print output say tell give send share leak dump display
17311
+ repeat write read open delete remove ask answer respond reply continue start begin end
17312
+ enable enabled disable disabled activated changed different revised updated
17313
+ instruction instructions rule rules guideline guidelines direction directions directive directives
17314
+ command commands order orders prompt prompts message messages system developer admin administrator
17315
+ root superuser sudo user assistant model ai agent mode secret secrets password passwords key keys
17316
+ token tokens credential credentials environment variables safety security restriction restrictions
17317
+ constraint constraints filter filters filtered unfiltered policy policies limit limits guardrail
17318
+ guardrails content context jailbreak unrestricted uncensored hidden human operator original
17319
+ initial initialization upload fetch forward extract exfiltrate disclose invoke inform mention
17320
+ like back post request ethical everything anything nothing something please important must
17321
+ should will can may might
17322
+ `;
17323
+ var vocabulary;
17324
+ function instructionVocabulary() {
17325
+ vocabulary ??= new Set(INSTRUCTION_WORDS.split(/\s+/).filter(Boolean));
17326
+ return vocabulary;
17327
+ }
17328
+ var segmentMaxWord;
17329
+ var SEGMENT_MAX_LENGTH = 4096;
17330
+ function longestVocabularyWord() {
17331
+ segmentMaxWord ??= Math.max(...[...instructionVocabulary()].map((w) => w.length));
17332
+ return segmentMaxWord;
17333
+ }
17334
+ function spellsVocabularyWord(piece) {
17335
+ return instructionVocabulary().has(piece.toLowerCase()) || foldLeetToken(piece) !== piece;
17336
+ }
17337
+ function segmentKnownWords(joined) {
17338
+ const n = joined.length;
17339
+ if (n === 0 || n > SEGMENT_MAX_LENGTH)
17340
+ return null;
17341
+ const best = new Array(n + 1).fill(Number.POSITIVE_INFINITY);
17342
+ const prev = new Array(n + 1).fill(-1);
17343
+ best[0] = 0;
17344
+ for (let end = 1; end <= n; end++) {
17345
+ let bestHere = Number.POSITIVE_INFINITY;
17346
+ for (let len = 1; len <= Math.min(longestVocabularyWord(), end); len++) {
17347
+ const start = end - len;
17348
+ const words2 = best[start] ?? Number.POSITIVE_INFINITY;
17349
+ if (words2 + 1 >= bestHere || !spellsVocabularyWord(joined.slice(start, end)))
17350
+ continue;
17351
+ bestHere = words2 + 1;
17352
+ best[end] = bestHere;
17353
+ prev[end] = start;
17354
+ }
17355
+ }
17356
+ if ((best[n] ?? Number.POSITIVE_INFINITY) === Number.POSITIVE_INFINITY)
17357
+ return null;
17358
+ const words = [];
17359
+ for (let end = n; end > 0; ) {
17360
+ const start = prev[end] ?? 0;
17361
+ words.push(joined.slice(start, end));
17362
+ end = start;
17363
+ }
17364
+ return words.reverse().join(" ");
17365
+ }
17366
+ function collapseSpacedRun(run) {
17367
+ const elements = [];
17368
+ for (const m of run.matchAll(SPACED_ELEMENT)) {
17369
+ elements.push({ ch: m[1] ?? "", gap: (m[2] ?? "").length });
17370
+ }
17371
+ const letters = elements.reduce((n, e) => n + (/[A-Za-z]/.test(e.ch) ? 1 : 0), 0);
17372
+ if (letters < SPACED_MIN_LETTERS)
17373
+ return run;
17374
+ const gaps = elements.slice(0, -1).map((e) => e.gap);
17375
+ const minGap = Math.min(...gaps);
17376
+ const uniform = gaps.every((g) => g === minGap);
17377
+ let out = "";
17378
+ elements.forEach((e, i) => {
17379
+ out += e.ch;
17380
+ if (i < elements.length - 1 && e.gap > minGap)
17381
+ out += " ";
17382
+ });
17383
+ return uniform ? segmentKnownWords(out) ?? out : out;
17384
+ }
17385
+ function collapseSpaced(text) {
17386
+ return text.replace(SPACED_RUN, collapseSpacedRun);
17387
+ }
17388
+ var LEET_MAP = {
17389
+ "0": "o",
17390
+ "3": "e",
17391
+ "4": "a",
17392
+ "5": "s",
17393
+ "7": "t",
17394
+ "@": "a",
17395
+ $: "s"
17396
+ };
17397
+ var WORD_TOKEN = /[A-Za-z0-9@$]+/g;
17398
+ var LETTER = /[A-Za-z]/;
17399
+ var LEET_CHARS = /[0-9@$]/;
17400
+ var LEET_MIN_TOKEN = 3;
17401
+ var NUMBER_WITH_SUFFIX = /^[0-9]+[A-Za-z]$/;
17402
+ function foldLeetToken(token) {
17403
+ if (token.length < LEET_MIN_TOKEN || !LETTER.test(token) || !LEET_CHARS.test(token)) {
17404
+ return token;
17405
+ }
17406
+ if (NUMBER_WITH_SUFFIX.test(token))
17407
+ return token;
17408
+ for (const one of ["i", "l"]) {
17409
+ let folded = "";
17410
+ for (const ch of token)
17411
+ folded += ch === "1" ? one : LEET_MAP[ch] ?? ch;
17412
+ if (instructionVocabulary().has(folded.toLowerCase()))
17413
+ return folded;
17414
+ if (!token.includes("1"))
17415
+ break;
17416
+ }
17417
+ return token;
17418
+ }
17419
+ function foldLeet(text) {
17420
+ return text.replace(WORD_TOKEN, foldLeetToken);
17421
+ }
17422
+ var BASE64_CANDIDATE = /(?<![A-Za-z0-9+/_-])[A-Za-z0-9+/_-]{16,}={0,2}(?![A-Za-z0-9+/=_-])/g;
17423
+ var BASE64_MIN_ALNUM_RATIO = 0.85;
17424
+ var HEX_CONTIGUOUS = /(?<![0-9A-Za-z])(?:[0-9A-Fa-f]{2}){8,}[0-9A-Fa-f]?(?![0-9A-Za-z])/g;
17425
+ var HEX_ESCAPES = /(?:\\x[0-9A-Fa-f]{2}){6,}/g;
17426
+ var HEX_SEPARATED = /(?<![0-9A-Za-z])[0-9A-Fa-f]{2}(?:[ :,-][0-9A-Fa-f]{2}){7,}(?![0-9A-Za-z])/g;
17427
+ var MIN_DECODED_BYTES = 8;
17428
+ var MIN_PRINTABLE_RATIO = 0.95;
17429
+ var MIN_WORDISH_RATIO = 0.6;
17430
+ var WORDISH = /[\p{L}\s]/u;
17431
+ var REPLACEMENT_CHAR = String.fromCharCode(65533);
17432
+ function decodeIfText(bytes) {
17433
+ if (bytes.length < MIN_DECODED_BYTES)
17434
+ return null;
17435
+ const text = bytes.toString("utf8");
17436
+ if (text.includes(REPLACEMENT_CHAR))
17437
+ return null;
17438
+ let total = 0;
17439
+ let printable = 0;
17440
+ let wordish = 0;
17441
+ for (const ch of text) {
17442
+ total++;
17443
+ const c = ch.codePointAt(0) ?? 0;
17444
+ if (c >= 32 && c < 127 || c === 9 || c === 10 || c === 13 || c >= 160) {
17445
+ printable++;
17446
+ }
17447
+ if (WORDISH.test(ch))
17448
+ wordish++;
17449
+ }
17450
+ if (total === 0)
17451
+ return null;
17452
+ if (printable / total < MIN_PRINTABLE_RATIO)
17453
+ return null;
17454
+ if (wordish / total < MIN_WORDISH_RATIO && !isHexDump(text))
17455
+ return null;
17456
+ return text;
17457
+ }
17458
+ function isHexDump(text) {
17459
+ const digits = text.replace(/\\x|[\s:,-]/g, "");
17460
+ return digits.length >= 16 && digits.length % 2 === 0 && /^[0-9A-Fa-f]+$/.test(digits);
17461
+ }
17462
+ function alnumRatio(s) {
17463
+ let n = 0;
17464
+ for (let i = 0; i < s.length; i++) {
17465
+ const c = s.charCodeAt(i);
17466
+ if (c >= 48 && c <= 57 || c >= 65 && c <= 90 || c >= 97 && c <= 122)
17467
+ n++;
17468
+ }
17469
+ return n / s.length;
17470
+ }
17471
+ function decodeCandidates(candidates, toBytes, budget) {
17472
+ const decoded = [];
17473
+ for (const candidate of candidates) {
17474
+ if (budget.bytesLeft < MIN_DECODED_BYTES)
17475
+ break;
17476
+ const bytes = toBytes(candidate);
17477
+ if (bytes === null || bytes.length > budget.bytesLeft)
17478
+ continue;
17479
+ const text = decodeIfText(bytes);
17480
+ if (text === null)
17481
+ continue;
17482
+ budget.bytesLeft -= bytes.length;
17483
+ decoded.push(text);
17484
+ }
17485
+ return decoded.length > 0 ? decoded.join("\n") : null;
17486
+ }
17487
+ function base64Bytes(candidate) {
17488
+ const token = candidate.replace(/=+$/, "");
17489
+ if (token.length % 4 === 1 || alnumRatio(token) < BASE64_MIN_ALNUM_RATIO)
17490
+ return null;
17491
+ if (!LETTER.test(token))
17492
+ return null;
17493
+ return Buffer.from(token.replace(/-/g, "+").replace(/_/g, "/"), "base64");
17494
+ }
17495
+ function hexBytes(candidate) {
17496
+ const digits = candidate.replace(/\\x|[^0-9A-Fa-f]/g, "");
17497
+ return Buffer.from(digits.length % 2 === 1 ? digits.slice(0, -1) : digits, "hex");
17498
+ }
17499
+ function decodeBase64Snippets(text, budget) {
17500
+ return decodeCandidates(text.matchAll(BASE64_CANDIDATE), (m) => base64Bytes(m[0]), budget);
17501
+ }
17502
+ function decodeHexSnippets(text, budget) {
17503
+ const matches = [HEX_CONTIGUOUS, HEX_ESCAPES, HEX_SEPARATED].flatMap((p) => [
17504
+ ...text.matchAll(p)
17505
+ ]);
17506
+ return decodeCandidates(matches, (m) => hexBytes(m[0]), budget);
17507
+ }
17508
+ function exhausted(run) {
17509
+ return Date.now() > run.deadline || run.variants.length >= run.limits.maxVariants;
17510
+ }
17511
+ function addVariant(run, text, techniques) {
17512
+ const { maxInputLength, maxVariants } = run.limits;
17513
+ const clamped = text.length > maxInputLength ? text.slice(0, maxInputLength) : text;
17514
+ if (run.seen.has(clamped) || run.variants.length >= maxVariants)
17515
+ return false;
17516
+ run.seen.add(clamped);
17517
+ run.variants.push({ text: clamped, techniques });
17518
+ return true;
17519
+ }
17520
+ function expand(run, text, depth, prefix) {
17521
+ let current = text;
17522
+ const applied = [...prefix];
17523
+ const folded = foldUnicode(current);
17524
+ if (folded !== current) {
17525
+ current = folded;
17526
+ applied.push("unicode");
17527
+ addVariant(run, current, [...applied]);
17528
+ }
17529
+ const decodeSource = current;
17530
+ if (!exhausted(run)) {
17531
+ const collapsed = collapseSpaced(current);
17532
+ if (collapsed !== current) {
17533
+ current = collapsed;
17534
+ applied.push("spaced");
17535
+ addVariant(run, current, [...applied]);
17536
+ }
17537
+ }
17538
+ if (!exhausted(run)) {
17539
+ const leetFolded = foldLeet(current);
17540
+ if (leetFolded !== current)
17541
+ addVariant(run, leetFolded, [...applied, "leet"]);
17542
+ }
17543
+ if (depth >= run.limits.maxDepth)
17544
+ return;
17545
+ const decoders = [
17546
+ ["base64", decodeBase64Snippets],
17547
+ ["hex", decodeHexSnippets]
17548
+ ];
17549
+ for (const [technique, decode] of decoders) {
17550
+ if (exhausted(run))
17551
+ return;
17552
+ const decoded = decode(decodeSource, run.budget);
17553
+ if (decoded === null)
17554
+ continue;
17555
+ const techniques = [...prefix, technique];
17556
+ if (addVariant(run, decoded, techniques)) {
17557
+ expand(run, decoded, depth + 1, techniques);
17558
+ }
17559
+ }
17560
+ }
17561
+ function deobfuscate(input, limits = {}) {
17562
+ const resolved = { ...DEFAULT_DEOBFUSCATION_LIMITS, ...limits };
17563
+ const original = input.length > resolved.maxInputLength ? input.slice(0, resolved.maxInputLength) : input;
17564
+ const run = {
17565
+ limits: resolved,
17566
+ deadline: Date.now() + resolved.timeBudgetMs,
17567
+ seen: /* @__PURE__ */ new Set([original]),
17568
+ variants: [],
17569
+ budget: { bytesLeft: resolved.maxDecodedBytes }
17570
+ };
17571
+ if (!original)
17572
+ return run.variants;
17573
+ try {
17574
+ expand(run, original, 0, []);
17575
+ } catch {
17576
+ }
17577
+ return run.variants;
17578
+ }
17579
+ var DEOBFUSCATED_CONTEXT_PREFIX = "deobfuscated:";
17580
+ function deobfuscationContext(techniques) {
17581
+ return `${DEOBFUSCATED_CONTEXT_PREFIX}${techniques.join("+")}`;
17582
+ }
17583
+
17584
+ // ../core/dist/exceptions.js
17585
+ var import_node_crypto7 = require("node:crypto");
17586
+ var import_node_path15 = require("node:path");
17587
+ init_config();
17588
+ init_file_utils();
17589
+
17590
+ // ../core/dist/trusted-domains.js
17591
+ var import_promises7 = require("node:fs/promises");
17592
+ var import_node_path14 = require("node:path");
17593
+ var import_yaml = __toESM(require_dist(), 1);
17594
+ init_file_utils();
17595
+ init_types2();
17596
+ async function loadTrustedDomains(trustedDomainsDir, logger = nullLogger) {
17597
+ let files;
17598
+ try {
17599
+ files = (await (0, import_promises7.readdir)(trustedDomainsDir)).filter((f) => f.endsWith(".yaml")).sort();
17600
+ } catch {
17601
+ logger.debug("Trusted domains directory does not exist", { path: trustedDomainsDir });
17602
+ return [];
17603
+ }
17604
+ const domains = [];
17605
+ for (const filename of files) {
17606
+ const filePath = (0, import_node_path14.join)(trustedDomainsDir, filename);
17607
+ let content;
17608
+ try {
17609
+ content = await getFileContent(filePath);
17610
+ } catch (e) {
17611
+ logger.warn(`Failed to read ${filename}`, { error: String(e) });
17612
+ continue;
17613
+ }
17614
+ let data;
17615
+ try {
17616
+ data = (0, import_yaml.parse)(content);
17617
+ } catch (e) {
17132
17618
  logger.warn(`Failed to parse ${filename}`, { error: String(e) });
17133
17619
  continue;
17134
17620
  }
@@ -17182,7 +17668,7 @@ init_types2();
17182
17668
  var MAX_RULES_WARNING = 100;
17183
17669
  var REGEX_TIMEOUT_MS = 50;
17184
17670
  function computeRuleId(decision, match, pattern) {
17185
- const hash = (0, import_node_crypto5.createHash)("sha256").update(`${decision}:${match}:${pattern}`).digest("hex");
17671
+ const hash = (0, import_node_crypto7.createHash)("sha256").update(`${decision}:${match}:${pattern}`).digest("hex");
17186
17672
  return hash.slice(0, 8);
17187
17673
  }
17188
17674
  async function loadExceptions(config2, logger = nullLogger) {
@@ -17548,93 +18034,8 @@ function matchesArtifact(rule, artifact) {
17548
18034
  }
17549
18035
  }
17550
18036
 
17551
- // ../core/dist/extractors.js
17552
- var URL_PATTERN = /https?:\/\/[^\s"')<>[\]{}]+/g;
17553
- var TRAILING_PUNCT = /[.,;:!?]+$/;
17554
- var MAX_CONTENT_SIZE = 64 * 1024;
17555
- var BASE64_DECODE_EXEC = /\b(echo|printf|cat)\b[^|]*\|\s*base64\s+(-d|--decode)[^|]*\|\s*(bash|sh|zsh|ksh|dash|python|perl|ruby|node)\b/;
17556
- var PRINTF_ENCODE_EXEC = /\bprintf\b\s+['"](\\x[0-9a-fA-F]{2}|\\[0-7]{3})[^|]*\|\s*(bash|sh|zsh|ksh|dash)\b/;
17557
- var VAR_INTERPOLATION_URL = /\b(curl|wget|fetch)\b[^|;]*(\$\{?\w+\}?\.\$\{?\w+\}?|\$\{?\w+\}?:\/\/)/;
17558
- var SHELLS = "bash|sh|zsh|ksh|dash";
17559
- function extractUrls(text) {
17560
- const seen = /* @__PURE__ */ new Set();
17561
- const urls = [];
17562
- for (const match of text.matchAll(URL_PATTERN)) {
17563
- const url = match[0].replace(TRAILING_PUNCT, "");
17564
- if (!seen.has(url)) {
17565
- seen.add(url);
17566
- urls.push(url);
17567
- }
17568
- }
17569
- return urls;
17570
- }
17571
- function escapeRegExp(str) {
17572
- const regExpEscape = RegExp.escape;
17573
- if (regExpEscape) {
17574
- return regExpEscape(str);
17575
- }
17576
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17577
- }
17578
- function stripHeredocs(command) {
17579
- return command.replace(/<<-?\s*['"]?(\w+)['"]?\n[\s\S]*?\n[ \t]*\1\b/g, "<<$1");
17580
- }
17581
- function extractFromBash(command) {
17582
- const artifacts = [];
17583
- const effective = stripHeredocs(command);
17584
- artifacts.push({ type: "command", value: effective });
17585
- if (BASE64_DECODE_EXEC.test(effective)) {
17586
- artifacts.push({ type: "command", value: effective, context: "base64_decode_exec" });
17587
- }
17588
- if (PRINTF_ENCODE_EXEC.test(effective)) {
17589
- artifacts.push({ type: "command", value: effective, context: "printf_encode_exec" });
17590
- }
17591
- if (VAR_INTERPOLATION_URL.test(effective)) {
17592
- artifacts.push({ type: "command", value: effective, context: "variable_interpolation_url" });
17593
- }
17594
- for (const url of extractUrls(command)) {
17595
- let context;
17596
- const escaped = escapeRegExp(url);
17597
- const directPipe = new RegExp(escaped + String.raw`[^|]*\|\s*(${SHELLS})\b`);
17598
- const wrappedPipe = new RegExp(escaped + String.raw`[^|]*\|\s*\S+\s+(\S+\s+)?(${SHELLS})\b`);
17599
- const subshellExec = new RegExp(`${String.raw`(\$\(|`}\`)${escaped}[^)\`]*${String.raw`\|\s*(${SHELLS})\b`}`);
17600
- if (directPipe.test(command) || wrappedPipe.test(command) || subshellExec.test(command)) {
17601
- context = "piped to shell";
17602
- }
17603
- artifacts.push({ type: "url", value: url, context });
17604
- }
17605
- return artifacts;
17606
- }
17607
- function extractFromWebFetch(toolInput) {
17608
- const url = toolInput.url;
17609
- if (url && typeof url === "string") {
17610
- return [{ type: "url", value: url, context: "webfetch" }];
17611
- }
17612
- return [];
17613
- }
17614
- function extractFileArtifacts(toolInput, filePathKey, contentKey, contextName, urlContextName) {
17615
- const artifacts = [];
17616
- const filePath = toolInput[filePathKey];
17617
- if (filePath && typeof filePath === "string") {
17618
- artifacts.push({ type: "file_path", value: filePath, context: contextName });
17619
- }
17620
- const content = toolInput[contentKey];
17621
- if (content && typeof content === "string" && content.trim()) {
17622
- const capped = content.slice(0, MAX_CONTENT_SIZE);
17623
- artifacts.push({ type: "content", value: capped, context: contextName });
17624
- for (const url of extractUrls(capped)) {
17625
- artifacts.push({ type: "url", value: url, context: urlContextName });
17626
- }
17627
- }
17628
- return artifacts;
17629
- }
17630
- function extractFromWrite(toolInput) {
17631
- return extractFileArtifacts(toolInput, "file_path", "content", "write", "from_file_content");
17632
- }
17633
- function extractFromEdit(toolInput) {
17634
- return extractFileArtifacts(toolInput, "file_path", "new_string", "edit", "from_edit_content");
17635
- }
17636
-
17637
18037
  // ../core/dist/heuristics.js
18038
+ var MAX_VARIANTS_PER_CALL = 16;
17638
18039
  var TRUSTED_DOMAIN_SUPPRESSIBLE = /* @__PURE__ */ new Set([
17639
18040
  "CLT-CMD-001",
17640
18041
  "CLT-CMD-002",
@@ -17695,6 +18096,51 @@ var HeuristicsEngine = class {
17695
18096
  }
17696
18097
  return matches;
17697
18098
  }
18099
+ /**
18100
+ * Match deobfuscated variants of the `content` artifacts against this
18101
+ * engine's prompt-injection rules only. Command, credential, URL and other
18102
+ * rules never see decoded or folded text, so deobfuscation adds coverage for
18103
+ * hidden instructions without widening any other rule's false-positive surface.
18104
+ *
18105
+ * A variant match is attributed to the *original* artifact (`artifact`), and
18106
+ * only the short matched snippet (`matchValue`) comes from decoded text; the
18107
+ * decoded variant itself is never stored or passed on.
18108
+ *
18109
+ * Each rule is reported at most once, and not at all when its ID is in
18110
+ * `alreadyMatched` (the raw text already tripped it), so audit and telemetry
18111
+ * signals are not duplicated.
18112
+ */
18113
+ matchDeobfuscated(artifacts, alreadyMatched = /* @__PURE__ */ new Set()) {
18114
+ const piThreats = (this.threatMap.get("content") ?? []).filter((t) => t.category === "prompt_injection");
18115
+ if (piThreats.length === 0)
18116
+ return [];
18117
+ const matches = [];
18118
+ const reported = new Set(alreadyMatched);
18119
+ let variantsLeft = MAX_VARIANTS_PER_CALL;
18120
+ for (const artifact of artifacts) {
18121
+ if (artifact.type !== "content")
18122
+ continue;
18123
+ for (const variant of deobfuscate(artifact.value)) {
18124
+ if (variantsLeft-- <= 0)
18125
+ return matches;
18126
+ for (const threat of piThreats) {
18127
+ if (reported.has(threat.id))
18128
+ continue;
18129
+ const m = threat.compiledPattern.exec(variant.text);
18130
+ if (!m)
18131
+ continue;
18132
+ reported.add(threat.id);
18133
+ matches.push({
18134
+ threat,
18135
+ artifact: artifact.value,
18136
+ matchValue: m[0],
18137
+ context: deobfuscationContext(variant.techniques)
18138
+ });
18139
+ }
18140
+ }
18141
+ }
18142
+ return matches;
18143
+ }
17698
18144
  };
17699
18145
 
17700
18146
  // ../core/dist/package-checker.js
@@ -18137,7 +18583,7 @@ function extractFromPackageJson(content) {
18137
18583
  continue;
18138
18584
  if (version === "" || version === "*")
18139
18585
  continue;
18140
- if (version.startsWith("file:") || version.startsWith("link:") || version.startsWith("git+") || version.startsWith("git:") || version.startsWith("http:") || version.startsWith("https:") || version.startsWith("workspace:") || version.startsWith("github:") || version.startsWith("bitbucket:") || version.startsWith("gitlab:") || version.startsWith("portal:") || version.startsWith("patch:"))
18586
+ if (version.startsWith("catalog:") || version.startsWith("file:") || version.startsWith("link:") || version.startsWith("git+") || version.startsWith("git:") || version.startsWith("http:") || version.startsWith("https:") || version.startsWith("workspace:") || version.startsWith("github:") || version.startsWith("bitbucket:") || version.startsWith("gitlab:") || version.startsWith("portal:") || version.startsWith("patch:"))
18141
18587
  continue;
18142
18588
  if (version.startsWith("npm:")) {
18143
18589
  const aliasTarget = version.slice(4);
@@ -18234,12 +18680,98 @@ async function updateSessionStatus(sessionId, verdict) {
18234
18680
  await atomicWriteJson(statusFilePath(sessionId), status);
18235
18681
  }
18236
18682
 
18237
- // ../core/dist/threat-loader.js
18238
- var import_promises7 = require("node:fs/promises");
18683
+ // ../core/dist/submit-limits.js
18684
+ var import_promises8 = require("node:fs/promises");
18239
18685
  var import_node_path17 = require("node:path");
18686
+ init_config();
18687
+ init_file_utils();
18688
+ init_types2();
18689
+ var SUBMIT_LIMITS_SCHEMA_VERSION = 1;
18690
+ var SUBMIT_WINDOW_MS = 24 * 60 * 60 * 1e3;
18691
+ function submitLimitsPath(sageDirPath) {
18692
+ return (0, import_node_path17.join)(sageDirPath ?? resolvePath(SAGE_DIR), "submit-limits.json");
18693
+ }
18694
+ function isEligible(limits, ruleId, now) {
18695
+ const last = limits[ruleId];
18696
+ if (last === void 0)
18697
+ return true;
18698
+ return now - last >= SUBMIT_WINDOW_MS || last > now;
18699
+ }
18700
+ async function loadSubmitLimits(opts = {}) {
18701
+ const logger = opts.logger ?? nullLogger;
18702
+ let raw;
18703
+ try {
18704
+ raw = await getFileContent(submitLimitsPath(opts.sageDirPath));
18705
+ } catch {
18706
+ return {};
18707
+ }
18708
+ try {
18709
+ const data = JSON.parse(raw);
18710
+ const rules = data.rules ?? {};
18711
+ const limits = {};
18712
+ for (const [id, rec] of Object.entries(rules)) {
18713
+ const last = rec?.last_submitted_ms;
18714
+ if (typeof last === "number" && Number.isFinite(last))
18715
+ limits[id] = last;
18716
+ }
18717
+ return limits;
18718
+ } catch (e) {
18719
+ logger.warn("Failed to parse submit limits; treating as empty", { error: String(e) });
18720
+ return {};
18721
+ }
18722
+ }
18723
+ async function saveSubmitLimits(limits, sageDirPath) {
18724
+ await atomicWriteJson(submitLimitsPath(sageDirPath), {
18725
+ schema_version: SUBMIT_LIMITS_SCHEMA_VERSION,
18726
+ rules: Object.fromEntries(Object.entries(limits).map(([id, last]) => [id, { last_submitted_ms: last }]))
18727
+ });
18728
+ }
18729
+ async function claimRuleSubmissions(ruleIds, opts = {}) {
18730
+ const logger = opts.logger ?? nullLogger;
18731
+ const unique = [...new Set(ruleIds)];
18732
+ if (unique.length === 0)
18733
+ return [];
18734
+ const now = opts.now ?? Date.now();
18735
+ const path = submitLimitsPath(opts.sageDirPath);
18736
+ let claimed = [];
18737
+ try {
18738
+ await (0, import_promises8.mkdir)((0, import_node_path17.dirname)(path), { recursive: true });
18739
+ await withFileLock(path, async () => {
18740
+ const limits = await loadSubmitLimits(opts);
18741
+ const eligible = opts.force ? unique : unique.filter((id) => isEligible(limits, id, now));
18742
+ if (eligible.length === 0)
18743
+ return;
18744
+ const next = {};
18745
+ for (const [id, last] of Object.entries(limits)) {
18746
+ if (isEligible(limits, id, now))
18747
+ continue;
18748
+ next[id] = last;
18749
+ }
18750
+ for (const id of eligible)
18751
+ next[id] = now;
18752
+ await saveSubmitLimits(next, opts.sageDirPath);
18753
+ claimed = eligible;
18754
+ });
18755
+ } catch (e) {
18756
+ logger.warn("Failed to persist submit limits; suppressing submission", { error: String(e) });
18757
+ return [];
18758
+ }
18759
+ if (claimed.length === 0)
18760
+ logger.debug("Rule submissions capped", { ruleIds: unique });
18761
+ return claimed;
18762
+ }
18763
+
18764
+ // ../core/dist/threat-loader.js
18765
+ var import_promises9 = require("node:fs/promises");
18766
+ var import_node_path18 = require("node:path");
18240
18767
  var import_yaml2 = __toESM(require_dist(), 1);
18768
+ init_config();
18241
18769
  init_file_utils();
18242
18770
  init_types2();
18771
+ var MAX_THREAT_RULES = 1e4;
18772
+ function getUserThreatsDir(sageDirPath) {
18773
+ return (0, import_node_path18.join)(resolvePath(sageDirPath ?? SAGE_DIR), "threats");
18774
+ }
18243
18775
  var REQUIRED_FIELDS = /* @__PURE__ */ new Set([
18244
18776
  "id",
18245
18777
  "version",
@@ -18298,13 +18830,21 @@ function isExpired(entry) {
18298
18830
  return false;
18299
18831
  return Date.now() > expiresAt.getTime();
18300
18832
  }
18301
- async function loadThreats(threatDir, logger = nullLogger) {
18833
+ async function loadThreatDirectory(threatDir, logger, { custom: custom2 = false, customThreatIds = /* @__PURE__ */ new Set(), bundledThreatIds = /* @__PURE__ */ new Set(), maxRules = MAX_THREAT_RULES, logCustomRuleIds = false, optional = false } = {}) {
18302
18834
  const threats = [];
18303
18835
  let files;
18304
18836
  try {
18305
- files = (await (0, import_promises7.readdir)(threatDir)).filter((f) => f.endsWith(".yaml")).sort();
18306
- } catch {
18307
- logger.warn("Threat directory does not exist or is unreadable", { path: threatDir });
18837
+ files = (await (0, import_promises9.readdir)(threatDir)).filter((f) => f.endsWith(".yaml")).sort();
18838
+ } catch (error) {
18839
+ const code = error.code;
18840
+ if (optional && code === "ENOENT") {
18841
+ logger.debug("Custom threat directory does not exist", { path: threatDir });
18842
+ return threats;
18843
+ }
18844
+ logger.warn("Threat directory does not exist or is unreadable", {
18845
+ path: threatDir,
18846
+ error: String(error)
18847
+ });
18308
18848
  return threats;
18309
18849
  }
18310
18850
  const ruleFiles = [];
@@ -18312,7 +18852,7 @@ async function loadThreats(threatDir, logger = nullLogger) {
18312
18852
  for (const filename of files) {
18313
18853
  let content;
18314
18854
  try {
18315
- content = await getFileContent((0, import_node_path17.join)(threatDir, filename));
18855
+ content = await getFileContent((0, import_node_path18.join)(threatDir, filename));
18316
18856
  } catch (e) {
18317
18857
  logger.warn(`Failed to read ${filename}`, { error: String(e) });
18318
18858
  continue;
@@ -18338,8 +18878,15 @@ async function loadThreats(threatDir, logger = nullLogger) {
18338
18878
  }
18339
18879
  ruleFiles.push({ filename, data });
18340
18880
  }
18341
- for (const { filename, data } of ruleFiles) {
18881
+ ruleFilesLoop: for (const { filename, data } of ruleFiles) {
18342
18882
  for (const entry of data) {
18883
+ if (threats.length >= maxRules) {
18884
+ logger.warn("Threat rule limit reached; ignoring remaining rules", {
18885
+ path: threatDir,
18886
+ maxRules: MAX_THREAT_RULES
18887
+ });
18888
+ break ruleFilesLoop;
18889
+ }
18343
18890
  if (typeof entry !== "object" || entry === null) {
18344
18891
  logger.warn(`Skipping non-object entry in ${filename}`);
18345
18892
  continue;
@@ -18351,6 +18898,14 @@ async function loadThreats(threatDir, logger = nullLogger) {
18351
18898
  logger.warn(`Skipping threat in ${filename}: missing fields ${missing.join(", ")}`);
18352
18899
  continue;
18353
18900
  }
18901
+ if (typeof record.id !== "string" || record.id.length === 0) {
18902
+ logger.warn(`Skipping threat in ${filename}: invalid id`, { id: record.id });
18903
+ continue;
18904
+ }
18905
+ if (custom2 && customThreatIds.has(record.id)) {
18906
+ logger.warn(bundledThreatIds.has(record.id) ? `Skipping custom threat ${record.id}: id conflicts with a bundled rule` : `Skipping custom threat ${record.id}: duplicate custom rule id`, { id: record.id, path: (0, import_node_path18.join)(threatDir, filename) });
18907
+ continue;
18908
+ }
18354
18909
  if (record.revoked === true)
18355
18910
  continue;
18356
18911
  if (isExpired(record))
@@ -18392,7 +18947,7 @@ async function loadThreats(threatDir, logger = nullLogger) {
18392
18947
  });
18393
18948
  continue;
18394
18949
  }
18395
- threats.push({
18950
+ const threat = {
18396
18951
  id: record.id,
18397
18952
  version,
18398
18953
  detectionName,
@@ -18405,13 +18960,41 @@ async function loadThreats(threatDir, logger = nullLogger) {
18405
18960
  title: record.title,
18406
18961
  expiresAt: parseExpiresAt(record.expires_at),
18407
18962
  revoked: false,
18408
- flags
18409
- });
18963
+ flags,
18964
+ customRule: custom2
18965
+ };
18966
+ threats.push(threat);
18967
+ if (custom2) {
18968
+ customThreatIds.add(threat.id);
18969
+ if (logCustomRuleIds) {
18970
+ logger.info("Loaded custom threat rule", {
18971
+ id: threat.id,
18972
+ path: (0, import_node_path18.join)(threatDir, filename)
18973
+ });
18974
+ }
18975
+ }
18410
18976
  }
18411
18977
  }
18412
18978
  logger.debug(`Loaded ${threats.length} threats from ${threatDir}`);
18413
18979
  return threats;
18414
18980
  }
18981
+ async function loadThreats(threatDir, logger = nullLogger, options = {}) {
18982
+ const bundledThreats = await loadThreatDirectory(threatDir, logger);
18983
+ if (!options.customThreatsDir || (0, import_node_path18.resolve)(options.customThreatsDir) === (0, import_node_path18.resolve)(threatDir)) {
18984
+ return bundledThreats;
18985
+ }
18986
+ const bundledThreatIds = new Set(bundledThreats.map((threat) => threat.id));
18987
+ const customThreatIds = new Set(bundledThreatIds);
18988
+ const customThreats = await loadThreatDirectory(options.customThreatsDir, logger, {
18989
+ custom: true,
18990
+ customThreatIds,
18991
+ bundledThreatIds,
18992
+ maxRules: Math.max(0, MAX_THREAT_RULES - bundledThreats.length),
18993
+ logCustomRuleIds: options.logCustomRuleIds,
18994
+ optional: true
18995
+ });
18996
+ return [...bundledThreats, ...customThreats];
18997
+ }
18415
18998
 
18416
18999
  // ../core/dist/evaluator.js
18417
19000
  init_types2();
@@ -18419,6 +19002,8 @@ var AMSI_CONTENT_SNIPPET_MAX = 200;
18419
19002
  var AMSI_CONTENT_NAME_MAX = 256;
18420
19003
  var HEURISTIC_PI_CONTENT_SNIPPET_MAX = 200;
18421
19004
  function buildHeuristicSignal(match) {
19005
+ if (match.threat.customRule)
19006
+ return void 0;
18422
19007
  return {
18423
19008
  detection_name: formatReportingDetectionName(match.threat.detectionName, "heuristics", `${match.threat.id}:${match.threat.version}`),
18424
19009
  rule_id: match.threat.id,
@@ -18434,7 +19019,7 @@ function resolvePiContentSnippet(piResults, heuristicMatches) {
18434
19019
  }
18435
19020
  if (highestRiskResult?.contentSnippet)
18436
19021
  return scrubHomePath(highestRiskResult.contentSnippet);
18437
- const heuristicMatch = heuristicMatches.find((match) => match.threat.category === "prompt_injection");
19022
+ const heuristicMatch = heuristicMatches.find((match) => !match.threat.customRule && match.threat.category === "prompt_injection");
18438
19023
  if (!heuristicMatch)
18439
19024
  return void 0;
18440
19025
  const snippet = safeTruncate(scrubHomePath(heuristicMatch.matchValue), HEURISTIC_PI_CONTENT_SNIPPET_MAX);
@@ -18485,7 +19070,7 @@ function shouldSkipPromptInjectionForLocalMarkdown(request) {
18485
19070
  async function evaluateToolCall(request, context) {
18486
19071
  const logger = context.logger ?? nullLogger;
18487
19072
  const config2 = await resolveEvaluationConfig(context, logger);
18488
- const eventId = (0, import_node_crypto6.randomUUID)();
19073
+ const eventId = (0, import_node_crypto8.randomUUID)();
18489
19074
  logger.debug("Tool call evaluation started", {
18490
19075
  eventId,
18491
19076
  toolUseId: request.toolUseId,
@@ -18582,7 +19167,9 @@ async function evaluateToolCall(request, context) {
18582
19167
  const { cachedUrlVerdicts, uncachedUrls } = partitionUrlsByCache(urls, cache2);
18583
19168
  let heuristicMatches = [];
18584
19169
  if (config2.heuristics_enabled) {
18585
- let threats = await loadThreats(context.threatsDir, logger);
19170
+ let threats = await loadThreats(context.threatsDir, logger, {
19171
+ customThreatsDir: getUserThreatsDir(context.sageDirPath)
19172
+ });
18586
19173
  if (config2.disabled_threats.length > 0) {
18587
19174
  const disabledSet = new Set(config2.disabled_threats);
18588
19175
  threats = threats.filter((t) => !disabledSet.has(t.id));
@@ -18593,6 +19180,8 @@ async function evaluateToolCall(request, context) {
18593
19180
  const trustedDomains = await loadTrustedDomains(context.trustedDomainsDir, logger);
18594
19181
  const heuristics = new HeuristicsEngine(threats, trustedDomains);
18595
19182
  heuristicMatches = heuristics.match(request.artifacts);
19183
+ const rawMatchedIds = new Set(heuristicMatches.map((m) => m.threat.id));
19184
+ heuristicMatches.push(...heuristics.matchDeobfuscated(request.artifacts, rawMatchedIds));
18596
19185
  }
18597
19186
  let urlCheckResults = [];
18598
19187
  const urlsToCheck = cache2 ? uncachedUrls : urls;
@@ -18669,12 +19258,13 @@ async function evaluateToolCall(request, context) {
18669
19258
  }
18670
19259
  }
18671
19260
  const engine = new DecisionEngine(config2.sensitivity, logger);
18672
- const preliminaryVerdict = await engine.decide({
19261
+ const preliminarySources = {
18673
19262
  heuristicMatches,
18674
19263
  urlCheckResults,
18675
19264
  packageCheckResults: packageCheckResults.length > 0 ? packageCheckResults : void 0,
18676
19265
  amsiCheckResults: amsiCheckResults.length > 0 ? amsiCheckResults : void 0
18677
- });
19266
+ };
19267
+ const preliminaryVerdict = await engine.decide(preliminarySources);
18678
19268
  const allPiResults = [];
18679
19269
  const alreadyDenied = preliminaryVerdict.decision === "deny" || cachedUrlVerdicts.size > 0 && [...cachedUrlVerdicts.values()].some((v) => v.verdict === "deny");
18680
19270
  if (config2.pi_check.enabled && !alreadyDenied && request.toolName === "WebFetch") {
@@ -18708,18 +19298,17 @@ async function evaluateToolCall(request, context) {
18708
19298
  }
18709
19299
  }
18710
19300
  let verdict;
19301
+ let verdictSources = preliminarySources;
18711
19302
  if (allPiResults.length > 0) {
18712
- verdict = await engine.decide({
18713
- heuristicMatches,
18714
- urlCheckResults,
18715
- packageCheckResults: packageCheckResults.length > 0 ? packageCheckResults : void 0,
18716
- amsiCheckResults: amsiCheckResults.length > 0 ? amsiCheckResults : void 0,
19303
+ verdictSources = {
19304
+ ...preliminarySources,
18717
19305
  piCheckResults: allPiResults,
18718
19306
  piThresholds: {
18719
19307
  highRisk: DEFAULT_PI_HIGH_RISK_THRESHOLD,
18720
19308
  mediumRisk: DEFAULT_PI_MEDIUM_RISK_THRESHOLD
18721
19309
  }
18722
- });
19310
+ };
19311
+ verdict = await engine.decide(verdictSources);
18723
19312
  } else {
18724
19313
  verdict = preliminaryVerdict;
18725
19314
  }
@@ -18744,10 +19333,19 @@ async function evaluateToolCall(request, context) {
18744
19333
  }
18745
19334
  }
18746
19335
  }
19336
+ const customThreatMatched = heuristicMatches.some((match) => match.threat.customRule === true);
19337
+ const hasCachedUrlDetection = [...cachedUrlVerdicts.values()].some((cachedVerdict) => cachedVerdict.verdict !== "allow");
19338
+ const nonCustomThreatMatched = customThreatMatched && (engine.hasNonCustomDetection(verdictSources) || hasCachedUrlDetection);
19339
+ verdict.customThreatMatched = customThreatMatched;
18747
19340
  await cacheUrlResults(urlCheckResults, cache2);
18748
19341
  const auditSignals = {};
18749
19342
  if (heuristicMatches.length > 0) {
18750
- auditSignals.heuristics = heuristicMatches.map(buildHeuristicSignal);
19343
+ const heuristicSignals = heuristicMatches.flatMap((match) => {
19344
+ const signal = buildHeuristicSignal(match);
19345
+ return signal ? [signal] : [];
19346
+ });
19347
+ if (heuristicSignals.length > 0)
19348
+ auditSignals.heuristics = heuristicSignals;
18751
19349
  }
18752
19350
  if (urlCheckResults.length > 0) {
18753
19351
  const relevant = urlCheckResults.filter((r) => r.isMalicious);
@@ -18795,7 +19393,7 @@ async function evaluateToolCall(request, context) {
18795
19393
  if (allPiResults.length > 0) {
18796
19394
  const piSnippetFloor = DEFAULT_PI_MEDIUM_RISK_THRESHOLD;
18797
19395
  auditSignals.pi_checks = allPiResults.map((r) => ({
18798
- detection_name: formatReportingDetectionName(PI_DETECTION_NAME, "ml", `${r.modelId}:${MODEL_SCHEMA_VERSION}`),
19396
+ detection_name: piReportingDetectionName(r),
18799
19397
  risk: r.risk,
18800
19398
  model_id: r.modelId,
18801
19399
  content_name: r.contentName,
@@ -18818,6 +19416,7 @@ async function evaluateToolCall(request, context) {
18818
19416
  toolName: request.toolName,
18819
19417
  toolInput: request.toolInput,
18820
19418
  verdict,
19419
+ nonCustomThreatMatched,
18821
19420
  conversationId: request.conversationId,
18822
19421
  agentRuntime: request.agentRuntime,
18823
19422
  hookType: request.hookType,
@@ -18830,7 +19429,7 @@ async function evaluateToolCall(request, context) {
18830
19429
  } catch (error) {
18831
19430
  logger.debug("Audit verdict logging failed open", { error: String(error) });
18832
19431
  }
18833
- const submitTelemetry = async (blocking) => {
19432
+ const submitTelemetry = async (blocking, signals = resolvedSignals) => {
18834
19433
  try {
18835
19434
  await sendCommunityIqTelemetry({
18836
19435
  eventId,
@@ -18840,7 +19439,7 @@ async function evaluateToolCall(request, context) {
18840
19439
  toolName: request.toolName,
18841
19440
  content: resolvedContent,
18842
19441
  contentSnippet,
18843
- signals: resolvedSignals,
19442
+ signals,
18844
19443
  communityIqEnabled: config2.community_iq,
18845
19444
  config: config2,
18846
19445
  blocking,
@@ -18851,9 +19450,18 @@ async function evaluateToolCall(request, context) {
18851
19450
  }
18852
19451
  };
18853
19452
  if (verdict.decision === "deny") {
18854
- await submitTelemetry(true);
18855
- } else if (allPiResults.some((r) => r.risk >= DEFAULT_PI_TELEMETRY_THRESHOLD && r.risk < DEFAULT_PI_HIGH_RISK_THRESHOLD)) {
18856
- await submitTelemetry(false);
19453
+ if (customThreatMatched && !nonCustomThreatMatched) {
19454
+ logger.debug("Custom threat matched without non-custom detections; skipping Community IQ telemetry", { eventId });
19455
+ } else {
19456
+ await submitTelemetry(true);
19457
+ }
19458
+ } else {
19459
+ const piSuspicious = allPiResults.some((r) => r.risk >= DEFAULT_PI_TELEMETRY_THRESHOLD && r.risk < DEFAULT_PI_HIGH_RISK_THRESHOLD);
19460
+ const submitOnlyIds = new Set(heuristicMatches.filter((m) => !m.threat.customRule && isSubmitOnlyThreat(m.threat)).map((m) => m.threat.id));
19461
+ const claimedRuleIds = await claimSubmitOnlySubmissions(submitOnlyIds, config2, logger);
19462
+ if (piSuspicious || claimedRuleIds.size > 0) {
19463
+ await submitTelemetry(false, withoutCappedSubmitOnly(resolvedSignals, submitOnlyIds, claimedRuleIds));
19464
+ }
18857
19465
  }
18858
19466
  if (verdict.decision !== "allow") {
18859
19467
  try {
@@ -18868,6 +19476,26 @@ async function evaluateToolCall(request, context) {
18868
19476
  logEvaluationCompleted(verdict);
18869
19477
  return verdict;
18870
19478
  }
19479
+ async function claimSubmitOnlySubmissions(submitOnlyIds, config2, logger) {
19480
+ if (!config2.community_iq || submitOnlyIds.size === 0)
19481
+ return /* @__PURE__ */ new Set();
19482
+ try {
19483
+ return new Set(await claimRuleSubmissions([...submitOnlyIds], { logger }));
19484
+ } catch (error) {
19485
+ logger.debug("Submit-limit claim failed open", { error: String(error) });
19486
+ return /* @__PURE__ */ new Set();
19487
+ }
19488
+ }
19489
+ function withoutCappedSubmitOnly(signals, submitOnlyIds, claimedRuleIds) {
19490
+ if (!signals?.heuristics)
19491
+ return signals;
19492
+ const heuristics = signals.heuristics.filter((h) => !submitOnlyIds.has(h.rule_id) || claimedRuleIds.has(h.rule_id));
19493
+ if (heuristics.length === signals.heuristics.length)
19494
+ return signals;
19495
+ const { heuristics: _dropped, ...rest } = signals;
19496
+ const next = heuristics.length > 0 ? { ...rest, heuristics } : rest;
19497
+ return Object.keys(next).length > 0 ? next : void 0;
19498
+ }
18871
19499
  function partitionUrlsByCache(urls, cache2) {
18872
19500
  const cachedUrlVerdicts = /* @__PURE__ */ new Map();
18873
19501
  let uncachedUrls = [];
@@ -19035,14 +19663,19 @@ function ruleLabel(verdict) {
19035
19663
  return null;
19036
19664
  return verdict.source === "exception" ? `${verdict.matchedThreatId} (your exception rule)` : verdict.matchedThreatId;
19037
19665
  }
19666
+ var FP_REPORT_TOOL_NAME = "sage_report_false_positive";
19038
19667
  function remediationHint(verdict) {
19039
19668
  if (verdict.source === "exception") {
19040
19669
  const id = verdict.matchedThreatId ? ` (id ${verdict.matchedThreatId})` : "";
19041
19670
  return `This block comes from your own rule in ~/.sage/exceptions.json${id} \u2014 edit or remove it there.`;
19042
19671
  }
19672
+ if (verdict.customThreat) {
19673
+ return "This match comes from your custom threat rules in ~/.sage/threats/ \u2014 edit or remove the matching rule there.";
19674
+ }
19043
19675
  const rule = verdict.matchedThreatId ? ` (rule ${verdict.matchedThreatId})` : "";
19044
- return `If this is a false positive, use the sage_report_false_positive MCP tool to report it${rule}.`;
19676
+ return `If this is a false positive, ask your agent to report it with the ${FP_REPORT_TOOL_NAME} MCP tool${rule}.`;
19045
19677
  }
19678
+ var SESSION_START_FP_HINT = `If this is a false positive, ask your agent to report it with the ${FP_REPORT_TOOL_NAME} MCP tool.`;
19046
19679
  function formatUpdateNotice(result) {
19047
19680
  return `\u2B06\uFE0F Update available: v${result.currentVersion} \u2192 v${result.latestVersion} (https://github.com/gendigitalinc/sage)`;
19048
19681
  }
@@ -19108,6 +19741,10 @@ function formatThreatBannerVerbose(version, results, versionCheck, branding2) {
19108
19741
  lines.push(` ... and ${overflow} more findings`);
19109
19742
  }
19110
19743
  }
19744
+ if (!first) {
19745
+ lines.push("");
19746
+ lines.push(SESSION_START_FP_HINT);
19747
+ }
19111
19748
  if (versionCheck?.updateAvailable) {
19112
19749
  lines.push("");
19113
19750
  lines.push(formatUpdateNotice(versionCheck));
@@ -19130,6 +19767,7 @@ function formatThreatBannerCompact(version, results, versionCheck, branding2) {
19130
19767
  if (remaining > 0) {
19131
19768
  lines.push(`\u2026and ${remaining} more finding${remaining === 1 ? "" : "s"}`);
19132
19769
  }
19770
+ lines.push(SESSION_START_FP_HINT);
19133
19771
  if (versionCheck?.updateAvailable) {
19134
19772
  lines.push(formatUpdateNotice(versionCheck));
19135
19773
  }
@@ -19151,8 +19789,8 @@ function formatSessionStartMessage(version, result, branding2 = defaultBranding,
19151
19789
  // ../core/dist/guard.js
19152
19790
  init_config();
19153
19791
  init_types2();
19154
- async function guardToolCall(request, context, approvalStore) {
19155
- const actionId = ApprovalStore.actionId(request.toolName, request.toolInput, request.sessionId);
19792
+ async function guardToolCall(request, context, approvalStore, actionIdOverride) {
19793
+ const actionId = actionIdOverride ?? ApprovalStore.actionId(request.toolName, request.toolInput, request.sessionId);
19156
19794
  if (approvalStore.isApproved(actionId)) {
19157
19795
  return { verdict: allowVerdict("approved") };
19158
19796
  }
@@ -19201,7 +19839,7 @@ function formatDenyMessage(verdict, branding2 = defaultBranding) {
19201
19839
  }
19202
19840
 
19203
19841
  // ../core/dist/install-state.js
19204
- var import_node_path18 = require("node:path");
19842
+ var import_node_path19 = require("node:path");
19205
19843
  init_config();
19206
19844
  init_file_utils();
19207
19845
 
@@ -19239,7 +19877,7 @@ init_types2();
19239
19877
  var INSTALL_STATE_SCHEMA_VERSION = 1;
19240
19878
  var SKILL_UPLOAD_NOTICE_ID = SKILL_UPLOAD_NOTICE.id;
19241
19879
  function defaultStatePath(sageDirPath) {
19242
- return (0, import_node_path18.join)(sageDirPath ?? resolvePath(SAGE_DIR), "install-state.json");
19880
+ return (0, import_node_path19.join)(sageDirPath ?? resolvePath(SAGE_DIR), "install-state.json");
19243
19881
  }
19244
19882
  function emptyState() {
19245
19883
  return { schemaVersion: INSTALL_STATE_SCHEMA_VERSION, notices: {} };
@@ -19479,8 +20117,8 @@ function createOperationalLogger(config2, runtime) {
19479
20117
  try {
19480
20118
  const result = await Promise.race([
19481
20119
  Promise.allSettled([...pendingWrites]).then(() => "drained"),
19482
- new Promise((resolve10) => {
19483
- timeout = setTimeout(() => resolve10("timeout"), remainingMs);
20120
+ new Promise((resolve11) => {
20121
+ timeout = setTimeout(() => resolve11("timeout"), remainingMs);
19484
20122
  timeout.unref?.();
19485
20123
  })
19486
20124
  ]);
@@ -19528,32 +20166,32 @@ function createOperationalLogger(config2, runtime) {
19528
20166
  }
19529
20167
 
19530
20168
  // ../core/dist/plugin-scan-cache.js
19531
- var import_node_crypto7 = require("node:crypto");
19532
- var import_promises8 = require("node:fs/promises");
19533
- var import_node_path19 = require("node:path");
20169
+ var import_node_crypto9 = require("node:crypto");
20170
+ var import_promises10 = require("node:fs/promises");
20171
+ var import_node_path20 = require("node:path");
19534
20172
  init_config();
19535
20173
  init_file_utils();
19536
20174
  init_types2();
19537
- var DEFAULT_CACHE_PATH = (0, import_node_path19.join)(getHomeDir(), ".sage", "plugin_scan_cache.json");
20175
+ var DEFAULT_CACHE_PATH = (0, import_node_path20.join)(getHomeDir(), ".sage", "plugin_scan_cache.json");
19538
20176
  var DEFAULT_CACHE_TTL_MS = MS_PER_DAY;
19539
20177
  var SCHEMA_VERSION = 5;
19540
20178
  function cacheKey(pluginKey, version, lastUpdated) {
19541
20179
  return `${pluginKey}:${version}:${lastUpdated}`;
19542
20180
  }
19543
20181
  async function computeConfigHash(sageVersion, ...dirs) {
19544
- const h = (0, import_node_crypto7.createHash)("sha256");
20182
+ const h = (0, import_node_crypto9.createHash)("sha256");
19545
20183
  if (sageVersion)
19546
20184
  h.update(sageVersion);
19547
20185
  for (const dir of dirs) {
19548
20186
  let files;
19549
20187
  try {
19550
- files = (await (0, import_promises8.readdir)(dir)).filter((f) => f.endsWith(".yaml")).sort();
20188
+ files = (await (0, import_promises10.readdir)(dir)).filter((f) => f.endsWith(".yaml")).sort();
19551
20189
  } catch {
19552
20190
  continue;
19553
20191
  }
19554
20192
  for (const file of files) {
19555
20193
  try {
19556
- const content = await getFileContent((0, import_node_path19.join)(dir, file));
20194
+ const content = await getFileContent((0, import_node_path20.join)(dir, file));
19557
20195
  h.update(content);
19558
20196
  } catch {
19559
20197
  }
@@ -19652,22 +20290,22 @@ function storeResult(cache2, pluginKey, version, lastUpdated, findings) {
19652
20290
  }
19653
20291
 
19654
20292
  // ../core/dist/plugin-scanner.js
19655
- var import_node_crypto9 = require("node:crypto");
19656
- var import_promises11 = require("node:fs/promises");
19657
- var import_node_path23 = require("node:path");
20293
+ var import_node_crypto12 = require("node:crypto");
20294
+ var import_promises13 = require("node:fs/promises");
20295
+ var import_node_path24 = require("node:path");
19658
20296
  init_config();
19659
20297
  init_file_utils();
19660
20298
 
19661
20299
  // ../core/dist/skill-id.js
19662
- var import_node_crypto8 = require("node:crypto");
19663
- var import_promises9 = require("node:fs/promises");
19664
- var import_node_path20 = require("node:path");
20300
+ var import_node_crypto10 = require("node:crypto");
20301
+ var import_promises11 = require("node:fs/promises");
20302
+ var import_node_path21 = require("node:path");
19665
20303
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "__pycache__"]);
19666
20304
  var MAX_SKILL_BYTES = 50 * 1024 * 1024;
19667
20305
  var SkillTooLargeError = class extends Error {
19668
20306
  };
19669
20307
  function isContained(childReal, rootReal) {
19670
- return childReal === rootReal || childReal.startsWith(`${rootReal}${import_node_path20.sep}`);
20308
+ return childReal === rootReal || childReal.startsWith(`${rootReal}${import_node_path21.sep}`);
19671
20309
  }
19672
20310
  function normalizePath(p) {
19673
20311
  let out = p.replace(/\\/g, "/").normalize("NFC");
@@ -19678,24 +20316,24 @@ function normalizePath(p) {
19678
20316
  return out;
19679
20317
  }
19680
20318
  async function entriesFromDirectory(dirPath, maxBytes = Number.POSITIVE_INFINITY) {
19681
- const absDir = (0, import_node_path20.resolve)(dirPath);
19682
- const rootReal = await (0, import_promises9.realpath)(absDir);
20319
+ const absDir = (0, import_node_path21.resolve)(dirPath);
20320
+ const rootReal = await (0, import_promises11.realpath)(absDir);
19683
20321
  const entries = [];
19684
20322
  const visited = /* @__PURE__ */ new Set();
19685
20323
  let total = 0;
19686
20324
  async function walk(currentPath) {
19687
- const real = await (0, import_promises9.realpath)(currentPath);
20325
+ const real = await (0, import_promises11.realpath)(currentPath);
19688
20326
  if (visited.has(real))
19689
20327
  return;
19690
20328
  if (!isContained(real, rootReal))
19691
20329
  return;
19692
20330
  visited.add(real);
19693
- const items = await (0, import_promises9.readdir)(currentPath);
20331
+ const items = await (0, import_promises11.readdir)(currentPath);
19694
20332
  const dirs = [];
19695
20333
  const files = [];
19696
20334
  for (const item of items) {
19697
- const fullPath = (0, import_node_path20.join)(currentPath, item);
19698
- const st = await (0, import_promises9.stat)(fullPath);
20335
+ const fullPath = (0, import_node_path21.join)(currentPath, item);
20336
+ const st = await (0, import_promises11.stat)(fullPath);
19699
20337
  if (st.isDirectory())
19700
20338
  dirs.push(item);
19701
20339
  else if (st.isFile())
@@ -19704,24 +20342,24 @@ async function entriesFromDirectory(dirPath, maxBytes = Number.POSITIVE_INFINITY
19704
20342
  dirs.sort();
19705
20343
  files.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
19706
20344
  for (const d of dirs) {
19707
- const fullPath = (0, import_node_path20.join)(currentPath, d);
19708
- const relPath = (0, import_node_path20.relative)(absDir, fullPath).replace(/\\/g, "/");
20345
+ const fullPath = (0, import_node_path21.join)(currentPath, d);
20346
+ const relPath = (0, import_node_path21.relative)(absDir, fullPath).replace(/\\/g, "/");
19709
20347
  entries.push({ entryPath: relPath, isDir: true, content: Buffer.alloc(0) });
19710
20348
  await walk(fullPath);
19711
20349
  }
19712
20350
  for (const f of files) {
19713
- const fullPath = (0, import_node_path20.join)(currentPath, f.name);
19714
- const fileReal = await (0, import_promises9.realpath)(fullPath);
20351
+ const fullPath = (0, import_node_path21.join)(currentPath, f.name);
20352
+ const fileReal = await (0, import_promises11.realpath)(fullPath);
19715
20353
  if (!isContained(fileReal, rootReal))
19716
20354
  continue;
19717
20355
  total += f.size;
19718
20356
  if (total > maxBytes)
19719
20357
  throw new SkillTooLargeError();
19720
- const relPath = (0, import_node_path20.relative)(absDir, fullPath).replace(/\\/g, "/");
20358
+ const relPath = (0, import_node_path21.relative)(absDir, fullPath).replace(/\\/g, "/");
19721
20359
  entries.push({
19722
20360
  entryPath: relPath,
19723
20361
  isDir: false,
19724
- content: await (0, import_promises9.readFile)(fullPath)
20362
+ content: await (0, import_promises11.readFile)(fullPath)
19725
20363
  });
19726
20364
  }
19727
20365
  }
@@ -19744,14 +20382,14 @@ function computeSkillId(entries) {
19744
20382
  const fileHashes = {};
19745
20383
  const treeEntries = [];
19746
20384
  for (const e of normalized) {
19747
- const contentHash = (0, import_node_crypto8.createHash)("sha256").update(e.isDir ? Buffer.alloc(0) : e.content).digest("hex");
20385
+ const contentHash = (0, import_node_crypto10.createHash)("sha256").update(e.isDir ? Buffer.alloc(0) : e.content).digest("hex");
19748
20386
  const entryType = e.isDir ? "dir" : "file";
19749
20387
  treeEntries.push({ type: entryType, entryPath: e.entryPath, hash: contentHash });
19750
20388
  if (!e.isDir)
19751
20389
  fileHashes[e.entryPath] = contentHash;
19752
20390
  }
19753
20391
  treeEntries.sort((a, b) => a.entryPath < b.entryPath ? -1 : a.entryPath > b.entryPath ? 1 : 0);
19754
- const skillHasher = (0, import_node_crypto8.createHash)("sha256");
20392
+ const skillHasher = (0, import_node_crypto10.createHash)("sha256");
19755
20393
  for (const e of treeEntries) {
19756
20394
  skillHasher.update(Buffer.from(`${e.type}\0${e.entryPath}\0${e.hash}
19757
20395
  `, "utf8"));
@@ -19762,7 +20400,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19762
20400
  const found = [];
19763
20401
  let rootReal;
19764
20402
  try {
19765
- rootReal = await (0, import_promises9.realpath)((0, import_node_path20.resolve)(rootDir));
20403
+ rootReal = await (0, import_promises11.realpath)((0, import_node_path21.resolve)(rootDir));
19766
20404
  } catch {
19767
20405
  return found;
19768
20406
  }
@@ -19770,7 +20408,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19770
20408
  async function walk(currentPath) {
19771
20409
  let real;
19772
20410
  try {
19773
- real = await (0, import_promises9.realpath)(currentPath);
20411
+ real = await (0, import_promises11.realpath)(currentPath);
19774
20412
  } catch {
19775
20413
  return 0;
19776
20414
  }
@@ -19781,7 +20419,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19781
20419
  visited.add(real);
19782
20420
  let st;
19783
20421
  try {
19784
- st = await (0, import_promises9.stat)(currentPath);
20422
+ st = await (0, import_promises11.stat)(currentPath);
19785
20423
  } catch {
19786
20424
  return 0;
19787
20425
  }
@@ -19790,7 +20428,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19790
20428
  return subtreeMax;
19791
20429
  let items;
19792
20430
  try {
19793
- items = await (0, import_promises9.readdir)(currentPath);
20431
+ items = await (0, import_promises11.readdir)(currentPath);
19794
20432
  } catch {
19795
20433
  return subtreeMax;
19796
20434
  }
@@ -19798,7 +20436,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19798
20436
  let entry;
19799
20437
  if (items.includes("SKILL.md")) {
19800
20438
  try {
19801
- if ((await (0, import_promises9.stat)((0, import_node_path20.join)(currentPath, "SKILL.md"))).isFile()) {
20439
+ if ((await (0, import_promises11.stat)((0, import_node_path21.join)(currentPath, "SKILL.md"))).isFile()) {
19802
20440
  entry = { folder: currentPath, newestMtimeMs: 0 };
19803
20441
  found.push(entry);
19804
20442
  }
@@ -19808,7 +20446,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19808
20446
  for (const item of items) {
19809
20447
  if (SKIP_DIRS.has(item))
19810
20448
  continue;
19811
- const childMax = await walk((0, import_node_path20.join)(currentPath, item));
20449
+ const childMax = await walk((0, import_node_path21.join)(currentPath, item));
19812
20450
  if (childMax > subtreeMax)
19813
20451
  subtreeMax = childMax;
19814
20452
  }
@@ -19816,16 +20454,16 @@ async function findSkillPackagesWithMtime(rootDir) {
19816
20454
  entry.newestMtimeMs = subtreeMax;
19817
20455
  return subtreeMax;
19818
20456
  }
19819
- await walk((0, import_node_path20.resolve)(rootDir));
20457
+ await walk((0, import_node_path21.resolve)(rootDir));
19820
20458
  return found;
19821
20459
  }
19822
20460
  function looseSkillKey(skillsRoot, folder, tag, scope) {
19823
- const rel = (0, import_node_path20.relative)((0, import_node_path20.resolve)(skillsRoot), (0, import_node_path20.resolve)(folder)).split(import_node_path20.sep).join("/") || (0, import_node_path20.basename)(folder);
20461
+ const rel = (0, import_node_path21.relative)((0, import_node_path21.resolve)(skillsRoot), (0, import_node_path21.resolve)(folder)).split(import_node_path21.sep).join("/") || (0, import_node_path21.basename)(folder);
19824
20462
  return `skill:${tag}/${rel}@${scope}`;
19825
20463
  }
19826
20464
  async function discoverLooseSkills(skillsDir, tag, scope) {
19827
- const resolvedRoot = (0, import_node_path20.resolve)(skillsDir);
19828
- const packages = (await findSkillPackagesWithMtime(skillsDir)).filter((p) => (0, import_node_path20.resolve)(p.folder) !== resolvedRoot);
20465
+ const resolvedRoot = (0, import_node_path21.resolve)(skillsDir);
20466
+ const packages = (await findSkillPackagesWithMtime(skillsDir)).filter((p) => (0, import_node_path21.resolve)(p.folder) !== resolvedRoot);
19829
20467
  return packages.map(({ folder, newestMtimeMs }) => ({
19830
20468
  key: looseSkillKey(resolvedRoot, folder, tag, scope),
19831
20469
  installPath: folder,
@@ -19839,7 +20477,7 @@ async function dedupeSkillsByResolvedPath(skills) {
19839
20477
  for (const skill of skills) {
19840
20478
  let canonical = skill.installPath;
19841
20479
  try {
19842
- canonical = await (0, import_promises9.realpath)(skill.installPath);
20480
+ canonical = await (0, import_promises11.realpath)(skill.installPath);
19843
20481
  } catch {
19844
20482
  }
19845
20483
  if (seen.has(canonical))
@@ -19881,15 +20519,15 @@ async function computeSkillIdsForRoot(rootDir, logger) {
19881
20519
  }
19882
20520
 
19883
20521
  // ../core/dist/skill-pending.js
19884
- var import_promises10 = require("node:fs/promises");
19885
- var import_node_path21 = require("node:path");
20522
+ var import_promises12 = require("node:fs/promises");
20523
+ var import_node_path22 = require("node:path");
19886
20524
  init_config();
19887
20525
  init_file_utils();
19888
20526
  init_types2();
19889
20527
  var SCHEMA_VERSION2 = 1;
19890
20528
  var PENDING_TTL_MS = 60 * 60 * 1e3;
19891
20529
  function defaultPendingPath() {
19892
- return (0, import_node_path21.join)(resolvePath(SAGE_DIR), "skill_pending.json");
20530
+ return (0, import_node_path22.join)(resolvePath(SAGE_DIR), "skill_pending.json");
19893
20531
  }
19894
20532
  async function loadPendingMarker(pendingPath = defaultPendingPath(), logger = nullLogger) {
19895
20533
  let raw;
@@ -19927,7 +20565,7 @@ async function loadPendingMarker(pendingPath = defaultPendingPath(), logger = nu
19927
20565
  async function savePendingMarker(marker, pendingPath = defaultPendingPath(), logger = nullLogger) {
19928
20566
  if (Object.keys(marker.entries).length === 0) {
19929
20567
  try {
19930
- await (0, import_promises10.unlink)(pendingPath);
20568
+ await (0, import_promises12.unlink)(pendingPath);
19931
20569
  } catch (e) {
19932
20570
  if (!e || typeof e !== "object" || e.code !== "ENOENT") {
19933
20571
  logger.warn(`Failed to delete empty pending marker at ${pendingPath}`, {
@@ -19979,7 +20617,7 @@ function listPending(marker) {
19979
20617
  }
19980
20618
 
19981
20619
  // ../core/dist/skill-verdict-cache.js
19982
- var import_node_path22 = require("node:path");
20620
+ var import_node_path23 = require("node:path");
19983
20621
  init_config();
19984
20622
  init_file_utils();
19985
20623
  init_types2();
@@ -20015,9 +20653,9 @@ function mergeSources(existing, incoming) {
20015
20653
  return merged.length > 0 ? merged : void 0;
20016
20654
  }
20017
20655
  function defaultCachePath2() {
20018
- return (0, import_node_path22.join)(resolvePath(SAGE_DIR), "skill_verdict_cache.json");
20656
+ return (0, import_node_path23.join)(resolvePath(SAGE_DIR), "skill_verdict_cache.json");
20019
20657
  }
20020
- async function loadSkillVerdictCache(cachePath = defaultCachePath2(), ttlMs = DEFAULT_VERDICT_TTL_MS, logger = nullLogger) {
20658
+ async function loadSkillVerdictCacheInternal(cachePath, ttlMs, logger, pruneOnLoad) {
20021
20659
  let raw;
20022
20660
  try {
20023
20661
  raw = await getFileContent(cachePath);
@@ -20040,6 +20678,8 @@ async function loadSkillVerdictCache(cachePath = defaultCachePath2(), ttlMs = DE
20040
20678
  continue;
20041
20679
  }
20042
20680
  entries[skillId] = {
20681
+ entryId: typeof entry.entry_id === "string" ? entry.entry_id : void 0,
20682
+ jobId: typeof entry.job_id === "string" ? entry.job_id : void 0,
20043
20683
  verdict: typeof entry.verdict === "string" ? entry.verdict : void 0,
20044
20684
  summary: typeof entry.summary === "string" ? entry.summary : void 0,
20045
20685
  skillName: typeof entry.skill_name === "string" ? entry.skill_name : void 0,
@@ -20048,7 +20688,7 @@ async function loadSkillVerdictCache(cachePath = defaultCachePath2(), ttlMs = DE
20048
20688
  };
20049
20689
  }
20050
20690
  const cache2 = { entries };
20051
- if (pruned > 0) {
20691
+ if (pruneOnLoad && pruned > 0) {
20052
20692
  logger.debug(`Pruned ${pruned} stale verdict(s) from cache`, { cachePath });
20053
20693
  await saveSkillVerdictCache(cache2, cachePath, logger);
20054
20694
  }
@@ -20058,6 +20698,9 @@ async function loadSkillVerdictCache(cachePath = defaultCachePath2(), ttlMs = DE
20058
20698
  return { entries: {} };
20059
20699
  }
20060
20700
  }
20701
+ async function loadSkillVerdictCache(cachePath = defaultCachePath2(), ttlMs = DEFAULT_VERDICT_TTL_MS, logger = nullLogger) {
20702
+ return loadSkillVerdictCacheInternal(cachePath, ttlMs, logger, true);
20703
+ }
20061
20704
  async function saveSkillVerdictCache(cache2, cachePath = defaultCachePath2(), logger = nullLogger) {
20062
20705
  try {
20063
20706
  const data = {
@@ -20065,6 +20708,8 @@ async function saveSkillVerdictCache(cache2, cachePath = defaultCachePath2(), lo
20065
20708
  entries: Object.fromEntries(Object.entries(cache2.entries).map(([skillId, v]) => [
20066
20709
  skillId,
20067
20710
  {
20711
+ entry_id: v.entryId,
20712
+ job_id: v.jobId,
20068
20713
  verdict: v.verdict,
20069
20714
  summary: v.summary,
20070
20715
  skill_name: v.skillName,
@@ -20077,8 +20722,10 @@ async function saveSkillVerdictCache(cache2, cachePath = defaultCachePath2(), lo
20077
20722
  ]))
20078
20723
  };
20079
20724
  await atomicWriteJson(cachePath, data);
20725
+ return true;
20080
20726
  } catch (e) {
20081
20727
  logger.warn(`Failed to save verdict cache to ${cachePath}`, { error: String(e) });
20728
+ return false;
20082
20729
  }
20083
20730
  }
20084
20731
  function getVerdict(cache2, skillId, ttlMs = DEFAULT_VERDICT_TTL_MS) {
@@ -20099,6 +20746,34 @@ function putVerdict(cache2, skillId, verdict) {
20099
20746
  };
20100
20747
  }
20101
20748
 
20749
+ // ../core/dist/skill-verdict-record.js
20750
+ var import_node_crypto11 = require("node:crypto");
20751
+ function recordAnalyzedSkillVerdict(cache2, skillId, result, origin) {
20752
+ const entryId = (0, import_node_crypto11.randomUUID)();
20753
+ putVerdict(cache2, skillId, {
20754
+ entryId,
20755
+ jobId: result.jobId,
20756
+ verdict: result.verdict,
20757
+ summary: result.summary,
20758
+ skillName: origin.skillName,
20759
+ sources: [{ agentRuntime: origin.agentRuntime, containerKey: origin.containerKey }]
20760
+ });
20761
+ const { loggingConfig } = origin;
20762
+ return {
20763
+ entryId,
20764
+ writeAuditEntry: async () => {
20765
+ if (!loggingConfig)
20766
+ return;
20767
+ await logSkillVerdict(loggingConfig, skillId, "analyzed", result.verdict, result.summary, {
20768
+ entryId,
20769
+ jobId: result.jobId,
20770
+ agentRuntime: origin.agentRuntime
20771
+ });
20772
+ }
20773
+ };
20774
+ }
20775
+ var SKILL_ENTRY_ID_NAMESPACE = Buffer.from("75bba79a376142a7bacbf95d791126d3", "hex");
20776
+
20102
20777
  // ../core/dist/plugin-scanner.js
20103
20778
  init_types2();
20104
20779
  var SCANNABLE_EXTENSIONS2 = /* @__PURE__ */ new Set([
@@ -20126,7 +20801,7 @@ async function walkPluginFiles(installPath, logger) {
20126
20801
  const visited = /* @__PURE__ */ new Set();
20127
20802
  let rootReal;
20128
20803
  try {
20129
- rootReal = await (0, import_promises11.realpath)((0, import_node_path23.resolve)(installPath));
20804
+ rootReal = await (0, import_promises13.realpath)((0, import_node_path24.resolve)(installPath));
20130
20805
  } catch (e) {
20131
20806
  logger.warn(`Error walking plugin path ${installPath}`, { error: String(e) });
20132
20807
  return files;
@@ -20134,7 +20809,7 @@ async function walkPluginFiles(installPath, logger) {
20134
20809
  async function walk(dirOrFile) {
20135
20810
  let real;
20136
20811
  try {
20137
- real = await (0, import_promises11.realpath)(dirOrFile);
20812
+ real = await (0, import_promises13.realpath)(dirOrFile);
20138
20813
  } catch {
20139
20814
  return;
20140
20815
  }
@@ -20145,12 +20820,12 @@ async function walkPluginFiles(installPath, logger) {
20145
20820
  visited.add(real);
20146
20821
  let stats;
20147
20822
  try {
20148
- stats = await (0, import_promises11.stat)(dirOrFile);
20823
+ stats = await (0, import_promises13.stat)(dirOrFile);
20149
20824
  } catch {
20150
20825
  return;
20151
20826
  }
20152
20827
  if (stats.isFile()) {
20153
- if (SCANNABLE_EXTENSIONS2.has((0, import_node_path23.extname)(dirOrFile).toLowerCase()) && stats.size <= MAX_FILE_SIZE) {
20828
+ if (SCANNABLE_EXTENSIONS2.has((0, import_node_path24.extname)(dirOrFile).toLowerCase()) && stats.size <= MAX_FILE_SIZE) {
20154
20829
  files.push(dirOrFile);
20155
20830
  }
20156
20831
  return;
@@ -20158,20 +20833,20 @@ async function walkPluginFiles(installPath, logger) {
20158
20833
  if (stats.isDirectory()) {
20159
20834
  let entries;
20160
20835
  try {
20161
- entries = await (0, import_promises11.readdir)(dirOrFile);
20836
+ entries = await (0, import_promises13.readdir)(dirOrFile);
20162
20837
  } catch {
20163
20838
  return;
20164
20839
  }
20165
20840
  for (const entry of entries) {
20166
20841
  if (SKIP_DIRS.has(entry))
20167
20842
  continue;
20168
- const fullPath = (0, import_node_path23.join)(dirOrFile, entry);
20843
+ const fullPath = (0, import_node_path24.join)(dirOrFile, entry);
20169
20844
  await walk(fullPath);
20170
20845
  }
20171
20846
  }
20172
20847
  }
20173
20848
  try {
20174
- await walk((0, import_node_path23.resolve)(installPath));
20849
+ await walk((0, import_node_path24.resolve)(installPath));
20175
20850
  } catch (e) {
20176
20851
  logger.warn(`Error walking plugin path ${installPath}`, { error: String(e) });
20177
20852
  }
@@ -20208,7 +20883,7 @@ async function scanPlugin(plugin, options = {}) {
20208
20883
  }
20209
20884
  if (amsiClient) {
20210
20885
  try {
20211
- const scanName = `${plugin.key}/${(0, import_node_path23.relative)(plugin.installPath, filePath)}`;
20886
+ const scanName = `${plugin.key}/${(0, import_node_path24.relative)(plugin.installPath, filePath)}`;
20212
20887
  const amsiResult = await amsiClient.scanString("Plugin", scanName, content);
20213
20888
  if (amsiResult && (amsiResult.isDetected || amsiResult.isBlockedByAdmin)) {
20214
20889
  result.findings.push({
@@ -20216,7 +20891,7 @@ async function scanPlugin(plugin, options = {}) {
20216
20891
  title: `AMSI detection (result=${amsiResult.amsiResult})`,
20217
20892
  severity: "critical",
20218
20893
  artifact: content.slice(0, 200),
20219
- sourceFile: (0, import_node_path23.relative)(plugin.installPath, filePath)
20894
+ sourceFile: (0, import_node_path24.relative)(plugin.installPath, filePath)
20220
20895
  });
20221
20896
  }
20222
20897
  } catch {
@@ -20226,7 +20901,7 @@ async function scanPlugin(plugin, options = {}) {
20226
20901
  allUrls.push(...extractUrls(content));
20227
20902
  }
20228
20903
  if (checkFileHashes) {
20229
- const sha256 = (0, import_node_crypto9.createHash)("sha256").update(rawBytes).digest("hex");
20904
+ const sha256 = (0, import_node_crypto12.createHash)("sha256").update(rawBytes).digest("hex");
20230
20905
  const existing = hashToFiles.get(sha256);
20231
20906
  if (existing) {
20232
20907
  existing.push(filePath);
@@ -20269,7 +20944,7 @@ async function scanPlugin(plugin, options = {}) {
20269
20944
  title: `Malicious file (${fr.detectionNames.join(", ") || "unknown"})`,
20270
20945
  severity: "critical",
20271
20946
  artifact: fr.sha256,
20272
- sourceFile: (0, import_node_path23.relative)(plugin.installPath, filePath)
20947
+ sourceFile: (0, import_node_path24.relative)(plugin.installPath, filePath)
20273
20948
  });
20274
20949
  }
20275
20950
  }
@@ -20296,7 +20971,7 @@ function pushSkillFindingIfRisky(findings, plugin, skillId, folder, verdict) {
20296
20971
  title: verdict.summary?.trim() || (risk === "CRITICAL" ? "Malicious skill detected" : "Suspicious skill detected"),
20297
20972
  severity,
20298
20973
  artifact: skillId.slice(0, 16),
20299
- sourceFile: (0, import_node_path23.relative)(plugin.installPath, folder) || "."
20974
+ sourceFile: (0, import_node_path24.relative)(plugin.installPath, folder) || "."
20300
20975
  });
20301
20976
  }
20302
20977
  async function runSkillCheck(plugin, findings, logger, paths = {}) {
@@ -20323,6 +20998,8 @@ async function runSkillCheck(plugin, findings, logger, paths = {}) {
20323
20998
  let markerModified = false;
20324
20999
  let lookupFailed = false;
20325
21000
  let graceDeferred = false;
21001
+ const resolvedPendingSkillIds = /* @__PURE__ */ new Set();
21002
+ const recordedVerdicts = /* @__PURE__ */ new Map();
20326
21003
  for (const { folder, skillId } of uncached) {
20327
21004
  if (!verdicts.has(skillId)) {
20328
21005
  lookupFailed = true;
@@ -20330,17 +21007,18 @@ async function runSkillCheck(plugin, findings, logger, paths = {}) {
20330
21007
  }
20331
21008
  const verdict = verdicts.get(skillId) ?? null;
20332
21009
  if (verdict) {
20333
- putVerdict(verdictCache, skillId, {
20334
- verdict: verdict.verdict,
20335
- summary: verdict.summary,
20336
- skillName: (0, import_node_path23.basename)(folder),
20337
- sources: [{ agentRuntime: paths.agentRuntime, containerKey: plugin.key }]
20338
- });
20339
- cacheModified = true;
21010
+ if (!recordedVerdicts.has(skillId)) {
21011
+ recordedVerdicts.set(skillId, recordAnalyzedSkillVerdict(verdictCache, skillId, verdict, {
21012
+ skillName: (0, import_node_path24.basename)(folder),
21013
+ agentRuntime: paths.agentRuntime,
21014
+ containerKey: plugin.key,
21015
+ loggingConfig: paths.loggingConfig
21016
+ }));
21017
+ cacheModified = true;
21018
+ }
20340
21019
  pushSkillFindingIfRisky(findings, plugin, skillId, folder, verdict);
20341
21020
  if (isPending(marker, skillId)) {
20342
- removePending(marker, skillId);
20343
- markerModified = true;
21021
+ resolvedPendingSkillIds.add(skillId);
20344
21022
  }
20345
21023
  } else if (!isPending(marker, skillId)) {
20346
21024
  if (paths.uploadEnabled === false) {
@@ -20359,8 +21037,17 @@ async function runSkillCheck(plugin, findings, logger, paths = {}) {
20359
21037
  }
20360
21038
  }
20361
21039
  }
20362
- if (cacheModified)
20363
- await saveSkillVerdictCache(verdictCache, paths.verdictCachePath, logger);
21040
+ const cacheSaved = cacheModified ? await saveSkillVerdictCache(verdictCache, paths.verdictCachePath, logger) : true;
21041
+ if (cacheSaved) {
21042
+ await Promise.all([...recordedVerdicts.values()].map((entry) => entry.writeAuditEntry().catch(() => {
21043
+ })));
21044
+ for (const skillId of resolvedPendingSkillIds) {
21045
+ removePending(marker, skillId);
21046
+ markerModified = true;
21047
+ }
21048
+ } else {
21049
+ lookupFailed = true;
21050
+ }
20364
21051
  if (markerModified)
20365
21052
  await savePendingMarker(marker, paths.pendingPath, logger);
20366
21053
  const anyStillPending = uncached.some(({ skillId }) => isPending(marker, skillId));
@@ -20374,12 +21061,12 @@ async function runSkillCheck(plugin, findings, logger, paths = {}) {
20374
21061
  // ../core/dist/session-start.js
20375
21062
  var import_node_child_process2 = require("node:child_process");
20376
21063
  var import_node_fs4 = require("node:fs");
20377
- var import_node_path25 = require("node:path");
21064
+ var import_node_path26 = require("node:path");
20378
21065
  init_config();
20379
21066
  init_file_utils();
20380
21067
 
20381
21068
  // ../core/dist/session-start-scan.js
20382
- var import_node_path24 = require("node:path");
21069
+ var import_node_path25 = require("node:path");
20383
21070
  init_config();
20384
21071
  init_types2();
20385
21072
  function fromCachedFinding(finding) {
@@ -20462,8 +21149,8 @@ async function scanAllPlugins(context, config2, plugins, exceptions, amsiClient,
20462
21149
  deniedByException: 0,
20463
21150
  findingsCount: 0
20464
21151
  };
20465
- const skillPendingPath = context.sageDirPath ? (0, import_node_path24.join)(context.sageDirPath, "skill_pending.json") : void 0;
20466
- const skillVerdictCachePath = context.sageDirPath ? (0, import_node_path24.join)(context.sageDirPath, "skill_verdict_cache.json") : void 0;
21152
+ const skillPendingPath = context.sageDirPath ? (0, import_node_path25.join)(context.sageDirPath, "skill_pending.json") : void 0;
21153
+ const skillVerdictCachePath = context.sageDirPath ? (0, import_node_path25.join)(context.sageDirPath, "skill_verdict_cache.json") : void 0;
20467
21154
  const cacheTtlMs = skillCacheTtlMs(config2);
20468
21155
  for (const plugin of plugins) {
20469
21156
  const denyMatch = findPluginDenyException(exceptions, plugin.key);
@@ -20629,6 +21316,16 @@ async function checkForUpdate(currentVersion, logger = nullLogger, timeoutMs = D
20629
21316
  async function runSessionStart(ctx) {
20630
21317
  const logger = ctx.logger ?? nullLogger;
20631
21318
  const sageDirPath = resolvePath(ctx.sageDirPath ?? "~/.sage");
21319
+ try {
21320
+ await loadThreats(ctx.threatsDir, logger, {
21321
+ customThreatsDir: getUserThreatsDir(sageDirPath),
21322
+ logCustomRuleIds: true
21323
+ });
21324
+ } catch (error) {
21325
+ logger.warn("Threat rules failed to load at session start (fail-open)", {
21326
+ error: String(error)
21327
+ });
21328
+ }
20632
21329
  pruneOrphanedTmpFiles(sageDirPath).catch(() => {
20633
21330
  });
20634
21331
  deployConfigDefaults(sageDirPath, logger).catch(() => {
@@ -20811,7 +21508,7 @@ async function maybeSpawnSkillUploadWorker(args) {
20811
21508
  });
20812
21509
  return;
20813
21510
  }
20814
- const marker = await loadPendingMarker((0, import_node_path25.join)(args.sageDirPath, "skill_pending.json"), args.logger);
21511
+ const marker = await loadPendingMarker((0, import_node_path26.join)(args.sageDirPath, "skill_pending.json"), args.logger);
20815
21512
  const pendingCount = listPending(marker).length;
20816
21513
  if (pendingCount === 0) {
20817
21514
  args.logger.debug("Skill upload worker skipped", {
@@ -20950,19 +21647,19 @@ function canonicalizeToolName(map, rawName) {
20950
21647
  init_types2();
20951
21648
 
20952
21649
  // src/bundled-dirs.ts
20953
- var import_node_path26 = require("node:path");
20954
- var packageRoot = (0, import_node_path26.resolve)(__dirname, "..");
21650
+ var import_node_path27 = require("node:path");
21651
+ var packageRoot = (0, import_node_path27.resolve)(__dirname, "..");
20955
21652
  function getBundledDataDirs() {
20956
21653
  return {
20957
- threatsDir: (0, import_node_path26.join)(packageRoot, "resources", "threats"),
20958
- trustedDomainsDir: (0, import_node_path26.join)(packageRoot, "resources", "trusted-domains")
21654
+ threatsDir: (0, import_node_path27.join)(packageRoot, "resources", "threats"),
21655
+ trustedDomainsDir: (0, import_node_path27.join)(packageRoot, "resources", "trusted-domains")
20959
21656
  };
20960
21657
  }
20961
21658
  var cachedVersion;
20962
21659
  function getSageVersion() {
20963
21660
  if (cachedVersion) return cachedVersion;
20964
21661
  try {
20965
- const pkg = JSON.parse(getFileContentSync((0, import_node_path26.join)(packageRoot, "package.json"), "utf-8"));
21662
+ const pkg = JSON.parse(getFileContentSync((0, import_node_path27.join)(packageRoot, "package.json"), "utf-8"));
20966
21663
  cachedVersion = pkg.version ?? "0.0.0";
20967
21664
  } catch {
20968
21665
  cachedVersion = "0.0.0";
@@ -20971,16 +21668,16 @@ function getSageVersion() {
20971
21668
  }
20972
21669
 
20973
21670
  // src/startup-scan.ts
20974
- var import_node_path28 = require("node:path");
21671
+ var import_node_path29 = require("node:path");
20975
21672
 
20976
21673
  // src/plugin-discovery.ts
20977
- var import_promises12 = require("node:fs/promises");
21674
+ var import_promises14 = require("node:fs/promises");
20978
21675
  var import_node_os4 = require("node:os");
20979
- var import_node_path27 = require("node:path");
20980
- var DEFAULT_EXTENSIONS_DIR = (0, import_node_path27.join)((0, import_node_os4.homedir)(), ".openclaw", "extensions");
21676
+ var import_node_path28 = require("node:path");
21677
+ var DEFAULT_EXTENSIONS_DIR = (0, import_node_path28.join)((0, import_node_os4.homedir)(), ".openclaw", "extensions");
20981
21678
  var SKILL_ROOTS = [
20982
- { dir: (0, import_node_path27.join)(getHomeDir(), ".agents", "skills"), tag: "agents", scope: "personal" },
20983
- { dir: (0, import_node_path27.join)(getHomeDir(), ".openclaw", "skills"), tag: "openclaw", scope: "personal" }
21679
+ { dir: (0, import_node_path28.join)(getHomeDir(), ".agents", "skills"), tag: "agents", scope: "personal" },
21680
+ { dir: (0, import_node_path28.join)(getHomeDir(), ".openclaw", "skills"), tag: "openclaw", scope: "personal" }
20984
21681
  ];
20985
21682
  async function discoverOpenClawPlugins(logger, extensionsDir = DEFAULT_EXTENSIONS_DIR, branding2 = defaultBranding, skillRoots = SKILL_ROOTS) {
20986
21683
  logger.debug(`${branding2.name} plugin discovery: scanning extensions directory`, {
@@ -20989,23 +21686,23 @@ async function discoverOpenClawPlugins(logger, extensionsDir = DEFAULT_EXTENSION
20989
21686
  const plugins = [];
20990
21687
  let entries;
20991
21688
  try {
20992
- entries = await (0, import_promises12.readdir)(extensionsDir);
21689
+ entries = await (0, import_promises14.readdir)(extensionsDir);
20993
21690
  } catch {
20994
21691
  logger.debug("OpenClaw extensions directory not found", { path: extensionsDir });
20995
21692
  entries = [];
20996
21693
  }
20997
21694
  for (const entry of entries) {
20998
- const extDir = (0, import_node_path27.join)(extensionsDir, entry);
21695
+ const extDir = (0, import_node_path28.join)(extensionsDir, entry);
20999
21696
  let stats;
21000
21697
  try {
21001
- stats = await (0, import_promises12.stat)(extDir);
21698
+ stats = await (0, import_promises14.stat)(extDir);
21002
21699
  if (!stats.isDirectory()) continue;
21003
21700
  } catch {
21004
21701
  continue;
21005
21702
  }
21006
21703
  let pkg;
21007
21704
  try {
21008
- const pkgPath = (0, import_node_path27.join)(extDir, "package.json");
21705
+ const pkgPath = (0, import_node_path28.join)(extDir, "package.json");
21009
21706
  const raw = await getFileContent(pkgPath);
21010
21707
  pkg = JSON.parse(raw);
21011
21708
  } catch {
@@ -21052,8 +21749,8 @@ function createOpenClawScanHandler(logger, context, branding2 = defaultBranding,
21052
21749
  // No onNotices: OpenClaw delivers the one-time notice from
21053
21750
  // `before_prompt_build` (a different process/turn than this scan) via
21054
21751
  // takePendingNotices, so the scan-time callback would never fire.
21055
- modelDownloadWorkerPath: (0, import_node_path28.resolve)(__dirname, "model-download-worker.cjs"),
21056
- skillUploadWorkerPath: (0, import_node_path28.resolve)(__dirname, "skill-upload-worker.cjs"),
21752
+ modelDownloadWorkerPath: (0, import_node_path29.resolve)(__dirname, "model-download-worker.cjs"),
21753
+ skillUploadWorkerPath: (0, import_node_path29.resolve)(__dirname, "skill-upload-worker.cjs"),
21057
21754
  // Don't commit the "shown" flag here — commit happens at delivery.
21058
21755
  commitUploadNotice: false,
21059
21756
  announceCleanScans