@gendigital/sage-openclaw 0.14.0 → 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
@@ -14501,6 +14505,47 @@ function buildContentSnapshot(toolType, toolInput, artifacts = [], signals = {})
14501
14505
  }
14502
14506
 
14503
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();
14504
14549
  var PI_DETECTION_NAME = "Other:SagePromptInjectionML-A [Susp]";
14505
14550
  var REPORTING_ENGINE_SHORTHANDS = {
14506
14551
  package: "sgpk",
@@ -14514,6 +14559,18 @@ function formatReportingDetectionName(canonicalName, engine, optionalData = "")
14514
14559
  const engineMetadata = optionalData ? `${REPORTING_ENGINE_SHORTHANDS[engine]}:${optionalData}` : REPORTING_ENGINE_SHORTHANDS[engine];
14515
14560
  return `${canonicalName}|${engineMetadata}|${REPORTING_DETECTION_SUFFIX}`;
14516
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
+ }
14517
14574
  var PACKAGE_DETECTION_NAMES = {
14518
14575
  not_found: "Other:SagePackageNotFound-A [Susp]",
14519
14576
  suspicious_age: "Other:SagePackageNew-A [Susp]",
@@ -14583,7 +14640,7 @@ init_file_utils();
14583
14640
 
14584
14641
  // ../core/dist/jsonl-log-writer.js
14585
14642
  var import_promises2 = require("node:fs/promises");
14586
- var import_node_path4 = require("node:path");
14643
+ var import_node_path5 = require("node:path");
14587
14644
  init_config();
14588
14645
  init_file_utils();
14589
14646
  var writeQueues = /* @__PURE__ */ new Map();
@@ -14620,7 +14677,7 @@ async function rotateIfNeeded(filePath, maxBytes, maxFiles) {
14620
14677
  });
14621
14678
  }
14622
14679
  async function appendJsonlEntryNow(path, config2, entry) {
14623
- 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 });
14624
14681
  await rotateIfNeeded(path, config2.max_bytes, config2.max_files);
14625
14682
  await (0, import_promises2.appendFile)(path, `${JSON.stringify(entry)}
14626
14683
  `);
@@ -14668,7 +14725,7 @@ async function logVerdict(config2, input) {
14668
14725
  return;
14669
14726
  const entry = {
14670
14727
  type: "runtime_verdict",
14671
- entry_id: input.eventId ?? (0, import_node_crypto3.randomUUID)(),
14728
+ entry_id: input.eventId ?? (0, import_node_crypto4.randomUUID)(),
14672
14729
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
14673
14730
  session_id: input.sessionId,
14674
14731
  conversation_id: input.conversationId ?? input.sessionId,
@@ -14682,6 +14739,8 @@ async function logVerdict(config2, input) {
14682
14739
  reasons: input.verdict.reasons,
14683
14740
  source: input.verdict.source,
14684
14741
  threat_id: input.verdict.matchedThreatId,
14742
+ ...input.verdict.customThreatMatched ? { custom_threat_matched: true } : {},
14743
+ ...input.nonCustomThreatMatched ? { non_custom_detection_matched: true } : {},
14685
14744
  user_override: userOverride,
14686
14745
  signals: input.signals,
14687
14746
  tool_use_id: input.toolUseId
@@ -14734,7 +14793,7 @@ async function logSkillVerdict(config2, skillId, status, verdict, summary, conte
14734
14793
  return false;
14735
14794
  const entry = {
14736
14795
  type: "skill_verdict",
14737
- entry_id: context.entryId ?? (0, import_node_crypto3.randomUUID)(),
14796
+ entry_id: context.entryId ?? (0, import_node_crypto4.randomUUID)(),
14738
14797
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
14739
14798
  skill_id: skillId,
14740
14799
  status
@@ -15198,19 +15257,19 @@ var PersistentPowershellAmsiBackend = class {
15198
15257
  return this.available;
15199
15258
  }
15200
15259
  waitForLine(timeout) {
15201
- return new Promise((resolve10, reject) => {
15260
+ return new Promise((resolve11, reject) => {
15202
15261
  const timer = setTimeout(() => {
15203
15262
  this.pendingResponse = null;
15204
15263
  reject(new Error("timeout"));
15205
15264
  }, timeout);
15206
- this.pendingResponse = { resolve: resolve10, reject, timer };
15265
+ this.pendingResponse = { resolve: resolve11, reject, timer };
15207
15266
  });
15208
15267
  }
15209
15268
  async enqueueScan(operation) {
15210
15269
  const previous = this.scanQueue;
15211
15270
  let release;
15212
- this.scanQueue = new Promise((resolve10) => {
15213
- release = resolve10;
15271
+ this.scanQueue = new Promise((resolve11) => {
15272
+ release = resolve11;
15214
15273
  });
15215
15274
  await previous;
15216
15275
  try {
@@ -15258,10 +15317,10 @@ var PersistentPowershellAmsiBackend = class {
15258
15317
  const line = this.stdoutBuffer.slice(0, idx).trim();
15259
15318
  this.stdoutBuffer = this.stdoutBuffer.slice(idx + 1);
15260
15319
  if (this.pendingResponse) {
15261
- const { resolve: resolve10, timer } = this.pendingResponse;
15320
+ const { resolve: resolve11, timer } = this.pendingResponse;
15262
15321
  this.pendingResponse = null;
15263
15322
  clearTimeout(timer);
15264
- resolve10(line);
15323
+ resolve11(line);
15265
15324
  }
15266
15325
  idx = this.stdoutBuffer.indexOf("\n");
15267
15326
  }
@@ -15360,7 +15419,7 @@ var WslPowershellAmsiBackend = class {
15360
15419
  if (!this.available)
15361
15420
  return null;
15362
15421
  const truncated = content.length > MAX_SCAN_LENGTH ? content.slice(0, MAX_SCAN_LENGTH) : content;
15363
- return new Promise((resolve10) => {
15422
+ return new Promise((resolve11) => {
15364
15423
  try {
15365
15424
  const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PS_ONESHOT_SCRIPT], { stdio: ["pipe", "pipe", "pipe"] });
15366
15425
  let stdout = "";
@@ -15370,7 +15429,7 @@ var WslPowershellAmsiBackend = class {
15370
15429
  ps.kill();
15371
15430
  } catch {
15372
15431
  }
15373
- resolve10(null);
15432
+ resolve11(null);
15374
15433
  }, PS_TIMEOUT);
15375
15434
  ps.stdout?.on("data", (chunk) => {
15376
15435
  stdout += chunk.toString();
@@ -15383,7 +15442,7 @@ var WslPowershellAmsiBackend = class {
15383
15442
  ps.on("error", (err) => {
15384
15443
  clearTimeout(timer);
15385
15444
  this.logger.debug("AMSI: PowerShell one-shot error", { error: String(err) });
15386
- resolve10(null);
15445
+ resolve11(null);
15387
15446
  });
15388
15447
  ps.on("exit", () => {
15389
15448
  clearTimeout(timer);
@@ -15396,11 +15455,11 @@ var WslPowershellAmsiBackend = class {
15396
15455
  contentName
15397
15456
  });
15398
15457
  }
15399
- resolve10(null);
15458
+ resolve11(null);
15400
15459
  return;
15401
15460
  }
15402
15461
  this.logger.debug("AMSI: PowerShell one-shot result", { contentName, amsiResult });
15403
- resolve10(interpretAmsiResult(amsiResult, content, contentName));
15462
+ resolve11(interpretAmsiResult(amsiResult, content, contentName));
15404
15463
  });
15405
15464
  const req = JSON.stringify({ content: truncated, contentName });
15406
15465
  ps.stdin?.write(`${req}
@@ -15408,7 +15467,7 @@ var WslPowershellAmsiBackend = class {
15408
15467
  ps.stdin?.end();
15409
15468
  } catch (e) {
15410
15469
  this.logger.warn("AMSI: PowerShell one-shot failed", { error: String(e), contentName });
15411
- resolve10(null);
15470
+ resolve11(null);
15412
15471
  }
15413
15472
  });
15414
15473
  }
@@ -15548,15 +15607,15 @@ var ContentFetchClient = class _ContentFetchClient {
15548
15607
  init_types2();
15549
15608
 
15550
15609
  // ../core/dist/version.js
15551
- var import_node_path5 = require("node:path");
15610
+ var import_node_path6 = require("node:path");
15552
15611
  var import_node_url = require("node:url");
15553
15612
  init_file_utils();
15554
15613
  var import_meta = {};
15555
15614
  function resolveVersion() {
15556
15615
  if (true)
15557
- return "0.14.0";
15616
+ return "0.15.0";
15558
15617
  try {
15559
- 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");
15560
15619
  const pkg = JSON.parse(getFileContentSync(pkgPath));
15561
15620
  if (typeof pkg.version === "string")
15562
15621
  return pkg.version;
@@ -15699,43 +15758,6 @@ var FileCheckClient = class {
15699
15758
  }
15700
15759
  };
15701
15760
 
15702
- // ../core/dist/model-storage.js
15703
- var import_node_fs = require("node:fs");
15704
- var import_node_path6 = require("node:path");
15705
- init_config();
15706
- var MODEL_SCHEMA_VERSION = "v2";
15707
- var REQUIRED_MODELS_BY_SCHEMA = {
15708
- v1: ["pi-model"],
15709
- v2: ["pi-model"]
15710
- };
15711
- var REQUIRED_MODEL_FILES = {
15712
- "pi-model": [
15713
- "model_int8.onnx",
15714
- "tokenizer.json",
15715
- "tokenizer_config.json",
15716
- "special_tokens_map.json",
15717
- "vocab.txt",
15718
- "config.json"
15719
- ]
15720
- };
15721
- function getModelStorageRoot(sageDir = resolvePath("~/.sage")) {
15722
- return (0, import_node_path6.join)(resolvePath(sageDir), "models");
15723
- }
15724
- function getModelDir(modelName, schema = MODEL_SCHEMA_VERSION, sageDir) {
15725
- return (0, import_node_path6.join)(getModelStorageRoot(sageDir), schema, modelName);
15726
- }
15727
- function isModelPresent(modelName, schema = MODEL_SCHEMA_VERSION, sageDir) {
15728
- const dir = getModelDir(modelName, schema, sageDir);
15729
- const files = REQUIRED_MODEL_FILES[modelName];
15730
- if (!files || files.length === 0)
15731
- return false;
15732
- return files.every((f) => (0, import_node_fs.existsSync)((0, import_node_path6.resolve)(dir, f)));
15733
- }
15734
- function missingRequiredModels(schema = MODEL_SCHEMA_VERSION, sageDir) {
15735
- const required = REQUIRED_MODELS_BY_SCHEMA[schema] ?? [];
15736
- return required.filter((name) => !isModelPresent(name, schema, sageDir));
15737
- }
15738
-
15739
15761
  // ../core/dist/clients/model-downloader.js
15740
15762
  init_types2();
15741
15763
  var STALE_LOCK_MS = 60 * 60 * 1e3;
@@ -16076,6 +16098,8 @@ var RegistryClient = class {
16076
16098
  };
16077
16099
 
16078
16100
  // ../core/dist/clients/pi-check.js
16101
+ var import_node_crypto5 = require("node:crypto");
16102
+ var import_promises3 = require("node:fs/promises");
16079
16103
  var import_node_path9 = require("node:path");
16080
16104
  init_types2();
16081
16105
  var DEFAULT_MAX_CONTENT_LENGTH2 = 16384;
@@ -16090,6 +16114,18 @@ var MODEL_METADATA_FILES = [
16090
16114
  "vocab.txt",
16091
16115
  MODEL_FILE
16092
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
+ }
16093
16129
  function softmax(logits) {
16094
16130
  const max = Math.max(...logits);
16095
16131
  const exp = logits.map((x) => Math.exp(x - max));
@@ -16114,6 +16150,7 @@ var BundledPiProvider = class _BundledPiProvider {
16114
16150
  logger;
16115
16151
  tokenizer = null;
16116
16152
  session = null;
16153
+ modelVersion;
16117
16154
  tensorClass = null;
16118
16155
  inputNames = /* @__PURE__ */ new Set();
16119
16156
  initPromise = null;
@@ -16179,6 +16216,7 @@ var BundledPiProvider = class _BundledPiProvider {
16179
16216
  findings,
16180
16217
  contentName: context,
16181
16218
  modelId,
16219
+ modelVersion: this.modelVersion,
16182
16220
  contentSnippet: maxChunk || void 0
16183
16221
  };
16184
16222
  this.logger.info("PI model inference completed", {
@@ -16221,6 +16259,7 @@ var BundledPiProvider = class _BundledPiProvider {
16221
16259
  this.session = await runtime.InferenceSession.create(modelFilePath);
16222
16260
  const sess = this.session;
16223
16261
  this.inputNames = new Set(sess.inputNames);
16262
+ this.modelVersion = await computeModelVersion(this.modelPath, this.logger);
16224
16263
  _BundledPiProvider.modelRuntimeLoaded = true;
16225
16264
  this.logger.info("PI model loaded", { path: this.modelPath });
16226
16265
  return true;
@@ -16423,7 +16462,7 @@ var SkillCheckClient = class {
16423
16462
  init_config();
16424
16463
 
16425
16464
  // ../core/dist/config-defaults.js
16426
- var import_promises3 = require("node:fs/promises");
16465
+ var import_promises4 = require("node:fs/promises");
16427
16466
  var import_node_path10 = require("node:path");
16428
16467
  init_file_utils();
16429
16468
  init_types2();
@@ -16454,11 +16493,11 @@ async function deployConfigDefaults(sageDirPath, logger = nullLogger) {
16454
16493
  const targetPath = (0, import_node_path10.join)(sageDirPath, CONFIG_DEFAULTS_FILENAME);
16455
16494
  const defaults = serializeConfigDefaults();
16456
16495
  try {
16457
- await (0, import_promises3.mkdir)(sageDirPath, { recursive: true, mode: 448 });
16458
- 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);
16459
16498
  let existing = null;
16460
16499
  try {
16461
- existing = await (0, import_promises3.readFile)(targetPath, "utf-8");
16500
+ existing = await (0, import_promises4.readFile)(targetPath, "utf-8");
16462
16501
  } catch {
16463
16502
  }
16464
16503
  if (existing) {
@@ -16475,7 +16514,7 @@ async function deployConfigDefaults(sageDirPath, logger = nullLogger) {
16475
16514
  return;
16476
16515
  }
16477
16516
  await withFileLock(targetPath, async () => {
16478
- 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 });
16479
16518
  logger.debug("Deployed config defaults", {
16480
16519
  path: targetPath,
16481
16520
  schemaVersion: CONFIG_DEFAULTS_SCHEMA_VERSION
@@ -16623,7 +16662,7 @@ function isScannableContent(content) {
16623
16662
  }
16624
16663
 
16625
16664
  // ../core/dist/extended-info.js
16626
- var import_promises4 = require("node:fs/promises");
16665
+ var import_promises5 = require("node:fs/promises");
16627
16666
  var import_node_os3 = require("node:os");
16628
16667
  var import_node_path11 = require("node:path");
16629
16668
  init_file_utils();
@@ -16703,7 +16742,7 @@ async function loadExtendedInfo(sageDirPath, logger = nullLogger) {
16703
16742
  async function loadExtendedInfoUncached(filePath, logger) {
16704
16743
  let size;
16705
16744
  try {
16706
- const info = await (0, import_promises4.stat)(filePath);
16745
+ const info = await (0, import_promises5.stat)(filePath);
16707
16746
  if (!info.isFile()) {
16708
16747
  logger.debug(`extended-info: not a regular file at ${filePath}`);
16709
16748
  return null;
@@ -16790,8 +16829,8 @@ function mergeExtendedInfo(envelope, extendedInfo) {
16790
16829
  }
16791
16830
 
16792
16831
  // ../core/dist/installation-id.js
16793
- var import_node_crypto4 = require("node:crypto");
16794
- var import_promises5 = require("node:fs/promises");
16832
+ var import_node_crypto6 = require("node:crypto");
16833
+ var import_promises6 = require("node:fs/promises");
16795
16834
  var import_node_path12 = require("node:path");
16796
16835
  init_config();
16797
16836
  init_file_utils();
@@ -16808,9 +16847,9 @@ async function getInstallationId(sageDirPath) {
16808
16847
  } catch {
16809
16848
  }
16810
16849
  try {
16811
- const id = (0, import_node_crypto4.randomUUID)();
16812
- await (0, import_promises5.mkdir)(sageDir, { recursive: true, mode: 448 });
16813
- 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" });
16814
16853
  return id;
16815
16854
  } catch (err) {
16816
16855
  if (err.code === "EEXIST") {
@@ -16994,6 +17033,10 @@ var DecisionEngine = class {
16994
17033
  this.askThreshold = policy.askThreshold;
16995
17034
  this.logger = logger;
16996
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
+ }
16997
17040
  async decide(sources) {
16998
17041
  const signals = this.collectSignals(sources.heuristicMatches, sources.urlCheckResults, sources.packageCheckResults, sources.amsiCheckResults, sources.piCheckResults, sources.piThresholds);
16999
17042
  if (signals.length === 0) {
@@ -17012,12 +17055,15 @@ var DecisionEngine = class {
17012
17055
  source: top.source,
17013
17056
  artifacts: allArtifacts,
17014
17057
  matchedThreatId: top.threatId,
17058
+ customThreat: top.customThreat,
17015
17059
  reasons: allReasons
17016
17060
  };
17017
17061
  }
17018
17062
  collectSignals(heuristicMatches, urlCheckResults, packageCheckResults, amsiCheckResults, piCheckResults, piThresholds) {
17019
17063
  const signals = [];
17020
17064
  for (const match of heuristicMatches) {
17065
+ if (isSubmitOnlyThreat(match.threat))
17066
+ continue;
17021
17067
  const { confidence, category, severity, id, title } = match.threat;
17022
17068
  if (applyPolicy(confidence, this.denyThreshold, this.askThreshold, this.logger) === "allow")
17023
17069
  continue;
@@ -17027,6 +17073,7 @@ var DecisionEngine = class {
17027
17073
  severity,
17028
17074
  source: "heuristic",
17029
17075
  threatId: id,
17076
+ customThreat: match.threat.customRule === true,
17030
17077
  reason: title,
17031
17078
  artifact: match.artifact
17032
17079
  });
@@ -17152,17 +17199,396 @@ var DecisionEngine = class {
17152
17199
  };
17153
17200
 
17154
17201
  // ../core/dist/evaluator.js
17155
- var import_node_crypto6 = require("node:crypto");
17202
+ var import_node_crypto8 = require("node:crypto");
17156
17203
  init_config();
17157
17204
 
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
+
17158
17584
  // ../core/dist/exceptions.js
17159
- var import_node_crypto5 = require("node:crypto");
17585
+ var import_node_crypto7 = require("node:crypto");
17160
17586
  var import_node_path15 = require("node:path");
17161
17587
  init_config();
17162
17588
  init_file_utils();
17163
17589
 
17164
17590
  // ../core/dist/trusted-domains.js
17165
- var import_promises6 = require("node:fs/promises");
17591
+ var import_promises7 = require("node:fs/promises");
17166
17592
  var import_node_path14 = require("node:path");
17167
17593
  var import_yaml = __toESM(require_dist(), 1);
17168
17594
  init_file_utils();
@@ -17170,7 +17596,7 @@ init_types2();
17170
17596
  async function loadTrustedDomains(trustedDomainsDir, logger = nullLogger) {
17171
17597
  let files;
17172
17598
  try {
17173
- files = (await (0, import_promises6.readdir)(trustedDomainsDir)).filter((f) => f.endsWith(".yaml")).sort();
17599
+ files = (await (0, import_promises7.readdir)(trustedDomainsDir)).filter((f) => f.endsWith(".yaml")).sort();
17174
17600
  } catch {
17175
17601
  logger.debug("Trusted domains directory does not exist", { path: trustedDomainsDir });
17176
17602
  return [];
@@ -17242,7 +17668,7 @@ init_types2();
17242
17668
  var MAX_RULES_WARNING = 100;
17243
17669
  var REGEX_TIMEOUT_MS = 50;
17244
17670
  function computeRuleId(decision, match, pattern) {
17245
- 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");
17246
17672
  return hash.slice(0, 8);
17247
17673
  }
17248
17674
  async function loadExceptions(config2, logger = nullLogger) {
@@ -17608,93 +18034,8 @@ function matchesArtifact(rule, artifact) {
17608
18034
  }
17609
18035
  }
17610
18036
 
17611
- // ../core/dist/extractors.js
17612
- var URL_PATTERN = /https?:\/\/[^\s"')<>[\]{}]+/g;
17613
- var TRAILING_PUNCT = /[.,;:!?]+$/;
17614
- var MAX_CONTENT_SIZE = 64 * 1024;
17615
- 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/;
17616
- var PRINTF_ENCODE_EXEC = /\bprintf\b\s+['"](\\x[0-9a-fA-F]{2}|\\[0-7]{3})[^|]*\|\s*(bash|sh|zsh|ksh|dash)\b/;
17617
- var VAR_INTERPOLATION_URL = /\b(curl|wget|fetch)\b[^|;]*(\$\{?\w+\}?\.\$\{?\w+\}?|\$\{?\w+\}?:\/\/)/;
17618
- var SHELLS = "bash|sh|zsh|ksh|dash";
17619
- function extractUrls(text) {
17620
- const seen = /* @__PURE__ */ new Set();
17621
- const urls = [];
17622
- for (const match of text.matchAll(URL_PATTERN)) {
17623
- const url = match[0].replace(TRAILING_PUNCT, "");
17624
- if (!seen.has(url)) {
17625
- seen.add(url);
17626
- urls.push(url);
17627
- }
17628
- }
17629
- return urls;
17630
- }
17631
- function escapeRegExp(str) {
17632
- const regExpEscape = RegExp.escape;
17633
- if (regExpEscape) {
17634
- return regExpEscape(str);
17635
- }
17636
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17637
- }
17638
- function stripHeredocs(command) {
17639
- return command.replace(/<<-?\s*['"]?(\w+)['"]?\n[\s\S]*?\n[ \t]*\1\b/g, "<<$1");
17640
- }
17641
- function extractFromBash(command) {
17642
- const artifacts = [];
17643
- const effective = stripHeredocs(command);
17644
- artifacts.push({ type: "command", value: effective });
17645
- if (BASE64_DECODE_EXEC.test(effective)) {
17646
- artifacts.push({ type: "command", value: effective, context: "base64_decode_exec" });
17647
- }
17648
- if (PRINTF_ENCODE_EXEC.test(effective)) {
17649
- artifacts.push({ type: "command", value: effective, context: "printf_encode_exec" });
17650
- }
17651
- if (VAR_INTERPOLATION_URL.test(effective)) {
17652
- artifacts.push({ type: "command", value: effective, context: "variable_interpolation_url" });
17653
- }
17654
- for (const url of extractUrls(command)) {
17655
- let context;
17656
- const escaped = escapeRegExp(url);
17657
- const directPipe = new RegExp(escaped + String.raw`[^|]*\|\s*(${SHELLS})\b`);
17658
- const wrappedPipe = new RegExp(escaped + String.raw`[^|]*\|\s*\S+\s+(\S+\s+)?(${SHELLS})\b`);
17659
- const subshellExec = new RegExp(`${String.raw`(\$\(|`}\`)${escaped}[^)\`]*${String.raw`\|\s*(${SHELLS})\b`}`);
17660
- if (directPipe.test(command) || wrappedPipe.test(command) || subshellExec.test(command)) {
17661
- context = "piped to shell";
17662
- }
17663
- artifacts.push({ type: "url", value: url, context });
17664
- }
17665
- return artifacts;
17666
- }
17667
- function extractFromWebFetch(toolInput) {
17668
- const url = toolInput.url;
17669
- if (url && typeof url === "string") {
17670
- return [{ type: "url", value: url, context: "webfetch" }];
17671
- }
17672
- return [];
17673
- }
17674
- function extractFileArtifacts(toolInput, filePathKey, contentKey, contextName, urlContextName) {
17675
- const artifacts = [];
17676
- const filePath = toolInput[filePathKey];
17677
- if (filePath && typeof filePath === "string") {
17678
- artifacts.push({ type: "file_path", value: filePath, context: contextName });
17679
- }
17680
- const content = toolInput[contentKey];
17681
- if (content && typeof content === "string" && content.trim()) {
17682
- const capped = content.slice(0, MAX_CONTENT_SIZE);
17683
- artifacts.push({ type: "content", value: capped, context: contextName });
17684
- for (const url of extractUrls(capped)) {
17685
- artifacts.push({ type: "url", value: url, context: urlContextName });
17686
- }
17687
- }
17688
- return artifacts;
17689
- }
17690
- function extractFromWrite(toolInput) {
17691
- return extractFileArtifacts(toolInput, "file_path", "content", "write", "from_file_content");
17692
- }
17693
- function extractFromEdit(toolInput) {
17694
- return extractFileArtifacts(toolInput, "file_path", "new_string", "edit", "from_edit_content");
17695
- }
17696
-
17697
18037
  // ../core/dist/heuristics.js
18038
+ var MAX_VARIANTS_PER_CALL = 16;
17698
18039
  var TRUSTED_DOMAIN_SUPPRESSIBLE = /* @__PURE__ */ new Set([
17699
18040
  "CLT-CMD-001",
17700
18041
  "CLT-CMD-002",
@@ -17755,6 +18096,51 @@ var HeuristicsEngine = class {
17755
18096
  }
17756
18097
  return matches;
17757
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
+ }
17758
18144
  };
17759
18145
 
17760
18146
  // ../core/dist/package-checker.js
@@ -18294,12 +18680,98 @@ async function updateSessionStatus(sessionId, verdict) {
18294
18680
  await atomicWriteJson(statusFilePath(sessionId), status);
18295
18681
  }
18296
18682
 
18297
- // ../core/dist/threat-loader.js
18298
- var import_promises7 = require("node:fs/promises");
18683
+ // ../core/dist/submit-limits.js
18684
+ var import_promises8 = require("node:fs/promises");
18299
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");
18300
18767
  var import_yaml2 = __toESM(require_dist(), 1);
18768
+ init_config();
18301
18769
  init_file_utils();
18302
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
+ }
18303
18775
  var REQUIRED_FIELDS = /* @__PURE__ */ new Set([
18304
18776
  "id",
18305
18777
  "version",
@@ -18358,13 +18830,21 @@ function isExpired(entry) {
18358
18830
  return false;
18359
18831
  return Date.now() > expiresAt.getTime();
18360
18832
  }
18361
- 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 } = {}) {
18362
18834
  const threats = [];
18363
18835
  let files;
18364
18836
  try {
18365
- files = (await (0, import_promises7.readdir)(threatDir)).filter((f) => f.endsWith(".yaml")).sort();
18366
- } catch {
18367
- 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
+ });
18368
18848
  return threats;
18369
18849
  }
18370
18850
  const ruleFiles = [];
@@ -18372,7 +18852,7 @@ async function loadThreats(threatDir, logger = nullLogger) {
18372
18852
  for (const filename of files) {
18373
18853
  let content;
18374
18854
  try {
18375
- content = await getFileContent((0, import_node_path17.join)(threatDir, filename));
18855
+ content = await getFileContent((0, import_node_path18.join)(threatDir, filename));
18376
18856
  } catch (e) {
18377
18857
  logger.warn(`Failed to read ${filename}`, { error: String(e) });
18378
18858
  continue;
@@ -18398,8 +18878,15 @@ async function loadThreats(threatDir, logger = nullLogger) {
18398
18878
  }
18399
18879
  ruleFiles.push({ filename, data });
18400
18880
  }
18401
- for (const { filename, data } of ruleFiles) {
18881
+ ruleFilesLoop: for (const { filename, data } of ruleFiles) {
18402
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
+ }
18403
18890
  if (typeof entry !== "object" || entry === null) {
18404
18891
  logger.warn(`Skipping non-object entry in ${filename}`);
18405
18892
  continue;
@@ -18411,6 +18898,14 @@ async function loadThreats(threatDir, logger = nullLogger) {
18411
18898
  logger.warn(`Skipping threat in ${filename}: missing fields ${missing.join(", ")}`);
18412
18899
  continue;
18413
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
+ }
18414
18909
  if (record.revoked === true)
18415
18910
  continue;
18416
18911
  if (isExpired(record))
@@ -18452,7 +18947,7 @@ async function loadThreats(threatDir, logger = nullLogger) {
18452
18947
  });
18453
18948
  continue;
18454
18949
  }
18455
- threats.push({
18950
+ const threat = {
18456
18951
  id: record.id,
18457
18952
  version,
18458
18953
  detectionName,
@@ -18465,13 +18960,41 @@ async function loadThreats(threatDir, logger = nullLogger) {
18465
18960
  title: record.title,
18466
18961
  expiresAt: parseExpiresAt(record.expires_at),
18467
18962
  revoked: false,
18468
- flags
18469
- });
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
+ }
18470
18976
  }
18471
18977
  }
18472
18978
  logger.debug(`Loaded ${threats.length} threats from ${threatDir}`);
18473
18979
  return threats;
18474
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
+ }
18475
18998
 
18476
18999
  // ../core/dist/evaluator.js
18477
19000
  init_types2();
@@ -18479,6 +19002,8 @@ var AMSI_CONTENT_SNIPPET_MAX = 200;
18479
19002
  var AMSI_CONTENT_NAME_MAX = 256;
18480
19003
  var HEURISTIC_PI_CONTENT_SNIPPET_MAX = 200;
18481
19004
  function buildHeuristicSignal(match) {
19005
+ if (match.threat.customRule)
19006
+ return void 0;
18482
19007
  return {
18483
19008
  detection_name: formatReportingDetectionName(match.threat.detectionName, "heuristics", `${match.threat.id}:${match.threat.version}`),
18484
19009
  rule_id: match.threat.id,
@@ -18494,7 +19019,7 @@ function resolvePiContentSnippet(piResults, heuristicMatches) {
18494
19019
  }
18495
19020
  if (highestRiskResult?.contentSnippet)
18496
19021
  return scrubHomePath(highestRiskResult.contentSnippet);
18497
- const heuristicMatch = heuristicMatches.find((match) => match.threat.category === "prompt_injection");
19022
+ const heuristicMatch = heuristicMatches.find((match) => !match.threat.customRule && match.threat.category === "prompt_injection");
18498
19023
  if (!heuristicMatch)
18499
19024
  return void 0;
18500
19025
  const snippet = safeTruncate(scrubHomePath(heuristicMatch.matchValue), HEURISTIC_PI_CONTENT_SNIPPET_MAX);
@@ -18545,7 +19070,7 @@ function shouldSkipPromptInjectionForLocalMarkdown(request) {
18545
19070
  async function evaluateToolCall(request, context) {
18546
19071
  const logger = context.logger ?? nullLogger;
18547
19072
  const config2 = await resolveEvaluationConfig(context, logger);
18548
- const eventId = (0, import_node_crypto6.randomUUID)();
19073
+ const eventId = (0, import_node_crypto8.randomUUID)();
18549
19074
  logger.debug("Tool call evaluation started", {
18550
19075
  eventId,
18551
19076
  toolUseId: request.toolUseId,
@@ -18642,7 +19167,9 @@ async function evaluateToolCall(request, context) {
18642
19167
  const { cachedUrlVerdicts, uncachedUrls } = partitionUrlsByCache(urls, cache2);
18643
19168
  let heuristicMatches = [];
18644
19169
  if (config2.heuristics_enabled) {
18645
- let threats = await loadThreats(context.threatsDir, logger);
19170
+ let threats = await loadThreats(context.threatsDir, logger, {
19171
+ customThreatsDir: getUserThreatsDir(context.sageDirPath)
19172
+ });
18646
19173
  if (config2.disabled_threats.length > 0) {
18647
19174
  const disabledSet = new Set(config2.disabled_threats);
18648
19175
  threats = threats.filter((t) => !disabledSet.has(t.id));
@@ -18653,6 +19180,8 @@ async function evaluateToolCall(request, context) {
18653
19180
  const trustedDomains = await loadTrustedDomains(context.trustedDomainsDir, logger);
18654
19181
  const heuristics = new HeuristicsEngine(threats, trustedDomains);
18655
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));
18656
19185
  }
18657
19186
  let urlCheckResults = [];
18658
19187
  const urlsToCheck = cache2 ? uncachedUrls : urls;
@@ -18729,12 +19258,13 @@ async function evaluateToolCall(request, context) {
18729
19258
  }
18730
19259
  }
18731
19260
  const engine = new DecisionEngine(config2.sensitivity, logger);
18732
- const preliminaryVerdict = await engine.decide({
19261
+ const preliminarySources = {
18733
19262
  heuristicMatches,
18734
19263
  urlCheckResults,
18735
19264
  packageCheckResults: packageCheckResults.length > 0 ? packageCheckResults : void 0,
18736
19265
  amsiCheckResults: amsiCheckResults.length > 0 ? amsiCheckResults : void 0
18737
- });
19266
+ };
19267
+ const preliminaryVerdict = await engine.decide(preliminarySources);
18738
19268
  const allPiResults = [];
18739
19269
  const alreadyDenied = preliminaryVerdict.decision === "deny" || cachedUrlVerdicts.size > 0 && [...cachedUrlVerdicts.values()].some((v) => v.verdict === "deny");
18740
19270
  if (config2.pi_check.enabled && !alreadyDenied && request.toolName === "WebFetch") {
@@ -18768,18 +19298,17 @@ async function evaluateToolCall(request, context) {
18768
19298
  }
18769
19299
  }
18770
19300
  let verdict;
19301
+ let verdictSources = preliminarySources;
18771
19302
  if (allPiResults.length > 0) {
18772
- verdict = await engine.decide({
18773
- heuristicMatches,
18774
- urlCheckResults,
18775
- packageCheckResults: packageCheckResults.length > 0 ? packageCheckResults : void 0,
18776
- amsiCheckResults: amsiCheckResults.length > 0 ? amsiCheckResults : void 0,
19303
+ verdictSources = {
19304
+ ...preliminarySources,
18777
19305
  piCheckResults: allPiResults,
18778
19306
  piThresholds: {
18779
19307
  highRisk: DEFAULT_PI_HIGH_RISK_THRESHOLD,
18780
19308
  mediumRisk: DEFAULT_PI_MEDIUM_RISK_THRESHOLD
18781
19309
  }
18782
- });
19310
+ };
19311
+ verdict = await engine.decide(verdictSources);
18783
19312
  } else {
18784
19313
  verdict = preliminaryVerdict;
18785
19314
  }
@@ -18804,10 +19333,19 @@ async function evaluateToolCall(request, context) {
18804
19333
  }
18805
19334
  }
18806
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;
18807
19340
  await cacheUrlResults(urlCheckResults, cache2);
18808
19341
  const auditSignals = {};
18809
19342
  if (heuristicMatches.length > 0) {
18810
- 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;
18811
19349
  }
18812
19350
  if (urlCheckResults.length > 0) {
18813
19351
  const relevant = urlCheckResults.filter((r) => r.isMalicious);
@@ -18855,7 +19393,7 @@ async function evaluateToolCall(request, context) {
18855
19393
  if (allPiResults.length > 0) {
18856
19394
  const piSnippetFloor = DEFAULT_PI_MEDIUM_RISK_THRESHOLD;
18857
19395
  auditSignals.pi_checks = allPiResults.map((r) => ({
18858
- detection_name: formatReportingDetectionName(PI_DETECTION_NAME, "ml", `${r.modelId}:${MODEL_SCHEMA_VERSION}`),
19396
+ detection_name: piReportingDetectionName(r),
18859
19397
  risk: r.risk,
18860
19398
  model_id: r.modelId,
18861
19399
  content_name: r.contentName,
@@ -18878,6 +19416,7 @@ async function evaluateToolCall(request, context) {
18878
19416
  toolName: request.toolName,
18879
19417
  toolInput: request.toolInput,
18880
19418
  verdict,
19419
+ nonCustomThreatMatched,
18881
19420
  conversationId: request.conversationId,
18882
19421
  agentRuntime: request.agentRuntime,
18883
19422
  hookType: request.hookType,
@@ -18890,7 +19429,7 @@ async function evaluateToolCall(request, context) {
18890
19429
  } catch (error) {
18891
19430
  logger.debug("Audit verdict logging failed open", { error: String(error) });
18892
19431
  }
18893
- const submitTelemetry = async (blocking) => {
19432
+ const submitTelemetry = async (blocking, signals = resolvedSignals) => {
18894
19433
  try {
18895
19434
  await sendCommunityIqTelemetry({
18896
19435
  eventId,
@@ -18900,7 +19439,7 @@ async function evaluateToolCall(request, context) {
18900
19439
  toolName: request.toolName,
18901
19440
  content: resolvedContent,
18902
19441
  contentSnippet,
18903
- signals: resolvedSignals,
19442
+ signals,
18904
19443
  communityIqEnabled: config2.community_iq,
18905
19444
  config: config2,
18906
19445
  blocking,
@@ -18911,9 +19450,18 @@ async function evaluateToolCall(request, context) {
18911
19450
  }
18912
19451
  };
18913
19452
  if (verdict.decision === "deny") {
18914
- await submitTelemetry(true);
18915
- } else if (allPiResults.some((r) => r.risk >= DEFAULT_PI_TELEMETRY_THRESHOLD && r.risk < DEFAULT_PI_HIGH_RISK_THRESHOLD)) {
18916
- 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
+ }
18917
19465
  }
18918
19466
  if (verdict.decision !== "allow") {
18919
19467
  try {
@@ -18928,6 +19476,26 @@ async function evaluateToolCall(request, context) {
18928
19476
  logEvaluationCompleted(verdict);
18929
19477
  return verdict;
18930
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
+ }
18931
19499
  function partitionUrlsByCache(urls, cache2) {
18932
19500
  const cachedUrlVerdicts = /* @__PURE__ */ new Map();
18933
19501
  let uncachedUrls = [];
@@ -19095,14 +19663,19 @@ function ruleLabel(verdict) {
19095
19663
  return null;
19096
19664
  return verdict.source === "exception" ? `${verdict.matchedThreatId} (your exception rule)` : verdict.matchedThreatId;
19097
19665
  }
19666
+ var FP_REPORT_TOOL_NAME = "sage_report_false_positive";
19098
19667
  function remediationHint(verdict) {
19099
19668
  if (verdict.source === "exception") {
19100
19669
  const id = verdict.matchedThreatId ? ` (id ${verdict.matchedThreatId})` : "";
19101
19670
  return `This block comes from your own rule in ~/.sage/exceptions.json${id} \u2014 edit or remove it there.`;
19102
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
+ }
19103
19675
  const rule = verdict.matchedThreatId ? ` (rule ${verdict.matchedThreatId})` : "";
19104
- 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}.`;
19105
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.`;
19106
19679
  function formatUpdateNotice(result) {
19107
19680
  return `\u2B06\uFE0F Update available: v${result.currentVersion} \u2192 v${result.latestVersion} (https://github.com/gendigitalinc/sage)`;
19108
19681
  }
@@ -19168,6 +19741,10 @@ function formatThreatBannerVerbose(version, results, versionCheck, branding2) {
19168
19741
  lines.push(` ... and ${overflow} more findings`);
19169
19742
  }
19170
19743
  }
19744
+ if (!first) {
19745
+ lines.push("");
19746
+ lines.push(SESSION_START_FP_HINT);
19747
+ }
19171
19748
  if (versionCheck?.updateAvailable) {
19172
19749
  lines.push("");
19173
19750
  lines.push(formatUpdateNotice(versionCheck));
@@ -19190,6 +19767,7 @@ function formatThreatBannerCompact(version, results, versionCheck, branding2) {
19190
19767
  if (remaining > 0) {
19191
19768
  lines.push(`\u2026and ${remaining} more finding${remaining === 1 ? "" : "s"}`);
19192
19769
  }
19770
+ lines.push(SESSION_START_FP_HINT);
19193
19771
  if (versionCheck?.updateAvailable) {
19194
19772
  lines.push(formatUpdateNotice(versionCheck));
19195
19773
  }
@@ -19211,8 +19789,8 @@ function formatSessionStartMessage(version, result, branding2 = defaultBranding,
19211
19789
  // ../core/dist/guard.js
19212
19790
  init_config();
19213
19791
  init_types2();
19214
- async function guardToolCall(request, context, approvalStore) {
19215
- 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);
19216
19794
  if (approvalStore.isApproved(actionId)) {
19217
19795
  return { verdict: allowVerdict("approved") };
19218
19796
  }
@@ -19261,7 +19839,7 @@ function formatDenyMessage(verdict, branding2 = defaultBranding) {
19261
19839
  }
19262
19840
 
19263
19841
  // ../core/dist/install-state.js
19264
- var import_node_path18 = require("node:path");
19842
+ var import_node_path19 = require("node:path");
19265
19843
  init_config();
19266
19844
  init_file_utils();
19267
19845
 
@@ -19299,7 +19877,7 @@ init_types2();
19299
19877
  var INSTALL_STATE_SCHEMA_VERSION = 1;
19300
19878
  var SKILL_UPLOAD_NOTICE_ID = SKILL_UPLOAD_NOTICE.id;
19301
19879
  function defaultStatePath(sageDirPath) {
19302
- 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");
19303
19881
  }
19304
19882
  function emptyState() {
19305
19883
  return { schemaVersion: INSTALL_STATE_SCHEMA_VERSION, notices: {} };
@@ -19539,8 +20117,8 @@ function createOperationalLogger(config2, runtime) {
19539
20117
  try {
19540
20118
  const result = await Promise.race([
19541
20119
  Promise.allSettled([...pendingWrites]).then(() => "drained"),
19542
- new Promise((resolve10) => {
19543
- timeout = setTimeout(() => resolve10("timeout"), remainingMs);
20120
+ new Promise((resolve11) => {
20121
+ timeout = setTimeout(() => resolve11("timeout"), remainingMs);
19544
20122
  timeout.unref?.();
19545
20123
  })
19546
20124
  ]);
@@ -19588,32 +20166,32 @@ function createOperationalLogger(config2, runtime) {
19588
20166
  }
19589
20167
 
19590
20168
  // ../core/dist/plugin-scan-cache.js
19591
- var import_node_crypto7 = require("node:crypto");
19592
- var import_promises8 = require("node:fs/promises");
19593
- 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");
19594
20172
  init_config();
19595
20173
  init_file_utils();
19596
20174
  init_types2();
19597
- 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");
19598
20176
  var DEFAULT_CACHE_TTL_MS = MS_PER_DAY;
19599
20177
  var SCHEMA_VERSION = 5;
19600
20178
  function cacheKey(pluginKey, version, lastUpdated) {
19601
20179
  return `${pluginKey}:${version}:${lastUpdated}`;
19602
20180
  }
19603
20181
  async function computeConfigHash(sageVersion, ...dirs) {
19604
- const h = (0, import_node_crypto7.createHash)("sha256");
20182
+ const h = (0, import_node_crypto9.createHash)("sha256");
19605
20183
  if (sageVersion)
19606
20184
  h.update(sageVersion);
19607
20185
  for (const dir of dirs) {
19608
20186
  let files;
19609
20187
  try {
19610
- 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();
19611
20189
  } catch {
19612
20190
  continue;
19613
20191
  }
19614
20192
  for (const file of files) {
19615
20193
  try {
19616
- const content = await getFileContent((0, import_node_path19.join)(dir, file));
20194
+ const content = await getFileContent((0, import_node_path20.join)(dir, file));
19617
20195
  h.update(content);
19618
20196
  } catch {
19619
20197
  }
@@ -19712,22 +20290,22 @@ function storeResult(cache2, pluginKey, version, lastUpdated, findings) {
19712
20290
  }
19713
20291
 
19714
20292
  // ../core/dist/plugin-scanner.js
19715
- var import_node_crypto10 = require("node:crypto");
19716
- var import_promises11 = require("node:fs/promises");
19717
- 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");
19718
20296
  init_config();
19719
20297
  init_file_utils();
19720
20298
 
19721
20299
  // ../core/dist/skill-id.js
19722
- var import_node_crypto8 = require("node:crypto");
19723
- var import_promises9 = require("node:fs/promises");
19724
- 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");
19725
20303
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "__pycache__"]);
19726
20304
  var MAX_SKILL_BYTES = 50 * 1024 * 1024;
19727
20305
  var SkillTooLargeError = class extends Error {
19728
20306
  };
19729
20307
  function isContained(childReal, rootReal) {
19730
- return childReal === rootReal || childReal.startsWith(`${rootReal}${import_node_path20.sep}`);
20308
+ return childReal === rootReal || childReal.startsWith(`${rootReal}${import_node_path21.sep}`);
19731
20309
  }
19732
20310
  function normalizePath(p) {
19733
20311
  let out = p.replace(/\\/g, "/").normalize("NFC");
@@ -19738,24 +20316,24 @@ function normalizePath(p) {
19738
20316
  return out;
19739
20317
  }
19740
20318
  async function entriesFromDirectory(dirPath, maxBytes = Number.POSITIVE_INFINITY) {
19741
- const absDir = (0, import_node_path20.resolve)(dirPath);
19742
- 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);
19743
20321
  const entries = [];
19744
20322
  const visited = /* @__PURE__ */ new Set();
19745
20323
  let total = 0;
19746
20324
  async function walk(currentPath) {
19747
- const real = await (0, import_promises9.realpath)(currentPath);
20325
+ const real = await (0, import_promises11.realpath)(currentPath);
19748
20326
  if (visited.has(real))
19749
20327
  return;
19750
20328
  if (!isContained(real, rootReal))
19751
20329
  return;
19752
20330
  visited.add(real);
19753
- const items = await (0, import_promises9.readdir)(currentPath);
20331
+ const items = await (0, import_promises11.readdir)(currentPath);
19754
20332
  const dirs = [];
19755
20333
  const files = [];
19756
20334
  for (const item of items) {
19757
- const fullPath = (0, import_node_path20.join)(currentPath, item);
19758
- 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);
19759
20337
  if (st.isDirectory())
19760
20338
  dirs.push(item);
19761
20339
  else if (st.isFile())
@@ -19764,24 +20342,24 @@ async function entriesFromDirectory(dirPath, maxBytes = Number.POSITIVE_INFINITY
19764
20342
  dirs.sort();
19765
20343
  files.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
19766
20344
  for (const d of dirs) {
19767
- const fullPath = (0, import_node_path20.join)(currentPath, d);
19768
- 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, "/");
19769
20347
  entries.push({ entryPath: relPath, isDir: true, content: Buffer.alloc(0) });
19770
20348
  await walk(fullPath);
19771
20349
  }
19772
20350
  for (const f of files) {
19773
- const fullPath = (0, import_node_path20.join)(currentPath, f.name);
19774
- 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);
19775
20353
  if (!isContained(fileReal, rootReal))
19776
20354
  continue;
19777
20355
  total += f.size;
19778
20356
  if (total > maxBytes)
19779
20357
  throw new SkillTooLargeError();
19780
- const relPath = (0, import_node_path20.relative)(absDir, fullPath).replace(/\\/g, "/");
20358
+ const relPath = (0, import_node_path21.relative)(absDir, fullPath).replace(/\\/g, "/");
19781
20359
  entries.push({
19782
20360
  entryPath: relPath,
19783
20361
  isDir: false,
19784
- content: await (0, import_promises9.readFile)(fullPath)
20362
+ content: await (0, import_promises11.readFile)(fullPath)
19785
20363
  });
19786
20364
  }
19787
20365
  }
@@ -19804,14 +20382,14 @@ function computeSkillId(entries) {
19804
20382
  const fileHashes = {};
19805
20383
  const treeEntries = [];
19806
20384
  for (const e of normalized) {
19807
- 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");
19808
20386
  const entryType = e.isDir ? "dir" : "file";
19809
20387
  treeEntries.push({ type: entryType, entryPath: e.entryPath, hash: contentHash });
19810
20388
  if (!e.isDir)
19811
20389
  fileHashes[e.entryPath] = contentHash;
19812
20390
  }
19813
20391
  treeEntries.sort((a, b) => a.entryPath < b.entryPath ? -1 : a.entryPath > b.entryPath ? 1 : 0);
19814
- const skillHasher = (0, import_node_crypto8.createHash)("sha256");
20392
+ const skillHasher = (0, import_node_crypto10.createHash)("sha256");
19815
20393
  for (const e of treeEntries) {
19816
20394
  skillHasher.update(Buffer.from(`${e.type}\0${e.entryPath}\0${e.hash}
19817
20395
  `, "utf8"));
@@ -19822,7 +20400,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19822
20400
  const found = [];
19823
20401
  let rootReal;
19824
20402
  try {
19825
- 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));
19826
20404
  } catch {
19827
20405
  return found;
19828
20406
  }
@@ -19830,7 +20408,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19830
20408
  async function walk(currentPath) {
19831
20409
  let real;
19832
20410
  try {
19833
- real = await (0, import_promises9.realpath)(currentPath);
20411
+ real = await (0, import_promises11.realpath)(currentPath);
19834
20412
  } catch {
19835
20413
  return 0;
19836
20414
  }
@@ -19841,7 +20419,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19841
20419
  visited.add(real);
19842
20420
  let st;
19843
20421
  try {
19844
- st = await (0, import_promises9.stat)(currentPath);
20422
+ st = await (0, import_promises11.stat)(currentPath);
19845
20423
  } catch {
19846
20424
  return 0;
19847
20425
  }
@@ -19850,7 +20428,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19850
20428
  return subtreeMax;
19851
20429
  let items;
19852
20430
  try {
19853
- items = await (0, import_promises9.readdir)(currentPath);
20431
+ items = await (0, import_promises11.readdir)(currentPath);
19854
20432
  } catch {
19855
20433
  return subtreeMax;
19856
20434
  }
@@ -19858,7 +20436,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19858
20436
  let entry;
19859
20437
  if (items.includes("SKILL.md")) {
19860
20438
  try {
19861
- 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()) {
19862
20440
  entry = { folder: currentPath, newestMtimeMs: 0 };
19863
20441
  found.push(entry);
19864
20442
  }
@@ -19868,7 +20446,7 @@ async function findSkillPackagesWithMtime(rootDir) {
19868
20446
  for (const item of items) {
19869
20447
  if (SKIP_DIRS.has(item))
19870
20448
  continue;
19871
- const childMax = await walk((0, import_node_path20.join)(currentPath, item));
20449
+ const childMax = await walk((0, import_node_path21.join)(currentPath, item));
19872
20450
  if (childMax > subtreeMax)
19873
20451
  subtreeMax = childMax;
19874
20452
  }
@@ -19876,16 +20454,16 @@ async function findSkillPackagesWithMtime(rootDir) {
19876
20454
  entry.newestMtimeMs = subtreeMax;
19877
20455
  return subtreeMax;
19878
20456
  }
19879
- await walk((0, import_node_path20.resolve)(rootDir));
20457
+ await walk((0, import_node_path21.resolve)(rootDir));
19880
20458
  return found;
19881
20459
  }
19882
20460
  function looseSkillKey(skillsRoot, folder, tag, scope) {
19883
- 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);
19884
20462
  return `skill:${tag}/${rel}@${scope}`;
19885
20463
  }
19886
20464
  async function discoverLooseSkills(skillsDir, tag, scope) {
19887
- const resolvedRoot = (0, import_node_path20.resolve)(skillsDir);
19888
- 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);
19889
20467
  return packages.map(({ folder, newestMtimeMs }) => ({
19890
20468
  key: looseSkillKey(resolvedRoot, folder, tag, scope),
19891
20469
  installPath: folder,
@@ -19899,7 +20477,7 @@ async function dedupeSkillsByResolvedPath(skills) {
19899
20477
  for (const skill of skills) {
19900
20478
  let canonical = skill.installPath;
19901
20479
  try {
19902
- canonical = await (0, import_promises9.realpath)(skill.installPath);
20480
+ canonical = await (0, import_promises11.realpath)(skill.installPath);
19903
20481
  } catch {
19904
20482
  }
19905
20483
  if (seen.has(canonical))
@@ -19941,15 +20519,15 @@ async function computeSkillIdsForRoot(rootDir, logger) {
19941
20519
  }
19942
20520
 
19943
20521
  // ../core/dist/skill-pending.js
19944
- var import_promises10 = require("node:fs/promises");
19945
- var import_node_path21 = require("node:path");
20522
+ var import_promises12 = require("node:fs/promises");
20523
+ var import_node_path22 = require("node:path");
19946
20524
  init_config();
19947
20525
  init_file_utils();
19948
20526
  init_types2();
19949
20527
  var SCHEMA_VERSION2 = 1;
19950
20528
  var PENDING_TTL_MS = 60 * 60 * 1e3;
19951
20529
  function defaultPendingPath() {
19952
- 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");
19953
20531
  }
19954
20532
  async function loadPendingMarker(pendingPath = defaultPendingPath(), logger = nullLogger) {
19955
20533
  let raw;
@@ -19987,7 +20565,7 @@ async function loadPendingMarker(pendingPath = defaultPendingPath(), logger = nu
19987
20565
  async function savePendingMarker(marker, pendingPath = defaultPendingPath(), logger = nullLogger) {
19988
20566
  if (Object.keys(marker.entries).length === 0) {
19989
20567
  try {
19990
- await (0, import_promises10.unlink)(pendingPath);
20568
+ await (0, import_promises12.unlink)(pendingPath);
19991
20569
  } catch (e) {
19992
20570
  if (!e || typeof e !== "object" || e.code !== "ENOENT") {
19993
20571
  logger.warn(`Failed to delete empty pending marker at ${pendingPath}`, {
@@ -20039,7 +20617,7 @@ function listPending(marker) {
20039
20617
  }
20040
20618
 
20041
20619
  // ../core/dist/skill-verdict-cache.js
20042
- var import_node_path22 = require("node:path");
20620
+ var import_node_path23 = require("node:path");
20043
20621
  init_config();
20044
20622
  init_file_utils();
20045
20623
  init_types2();
@@ -20075,7 +20653,7 @@ function mergeSources(existing, incoming) {
20075
20653
  return merged.length > 0 ? merged : void 0;
20076
20654
  }
20077
20655
  function defaultCachePath2() {
20078
- 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");
20079
20657
  }
20080
20658
  async function loadSkillVerdictCacheInternal(cachePath, ttlMs, logger, pruneOnLoad) {
20081
20659
  let raw;
@@ -20169,9 +20747,9 @@ function putVerdict(cache2, skillId, verdict) {
20169
20747
  }
20170
20748
 
20171
20749
  // ../core/dist/skill-verdict-record.js
20172
- var import_node_crypto9 = require("node:crypto");
20750
+ var import_node_crypto11 = require("node:crypto");
20173
20751
  function recordAnalyzedSkillVerdict(cache2, skillId, result, origin) {
20174
- const entryId = (0, import_node_crypto9.randomUUID)();
20752
+ const entryId = (0, import_node_crypto11.randomUUID)();
20175
20753
  putVerdict(cache2, skillId, {
20176
20754
  entryId,
20177
20755
  jobId: result.jobId,
@@ -20223,7 +20801,7 @@ async function walkPluginFiles(installPath, logger) {
20223
20801
  const visited = /* @__PURE__ */ new Set();
20224
20802
  let rootReal;
20225
20803
  try {
20226
- 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));
20227
20805
  } catch (e) {
20228
20806
  logger.warn(`Error walking plugin path ${installPath}`, { error: String(e) });
20229
20807
  return files;
@@ -20231,7 +20809,7 @@ async function walkPluginFiles(installPath, logger) {
20231
20809
  async function walk(dirOrFile) {
20232
20810
  let real;
20233
20811
  try {
20234
- real = await (0, import_promises11.realpath)(dirOrFile);
20812
+ real = await (0, import_promises13.realpath)(dirOrFile);
20235
20813
  } catch {
20236
20814
  return;
20237
20815
  }
@@ -20242,12 +20820,12 @@ async function walkPluginFiles(installPath, logger) {
20242
20820
  visited.add(real);
20243
20821
  let stats;
20244
20822
  try {
20245
- stats = await (0, import_promises11.stat)(dirOrFile);
20823
+ stats = await (0, import_promises13.stat)(dirOrFile);
20246
20824
  } catch {
20247
20825
  return;
20248
20826
  }
20249
20827
  if (stats.isFile()) {
20250
- 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) {
20251
20829
  files.push(dirOrFile);
20252
20830
  }
20253
20831
  return;
@@ -20255,20 +20833,20 @@ async function walkPluginFiles(installPath, logger) {
20255
20833
  if (stats.isDirectory()) {
20256
20834
  let entries;
20257
20835
  try {
20258
- entries = await (0, import_promises11.readdir)(dirOrFile);
20836
+ entries = await (0, import_promises13.readdir)(dirOrFile);
20259
20837
  } catch {
20260
20838
  return;
20261
20839
  }
20262
20840
  for (const entry of entries) {
20263
20841
  if (SKIP_DIRS.has(entry))
20264
20842
  continue;
20265
- const fullPath = (0, import_node_path23.join)(dirOrFile, entry);
20843
+ const fullPath = (0, import_node_path24.join)(dirOrFile, entry);
20266
20844
  await walk(fullPath);
20267
20845
  }
20268
20846
  }
20269
20847
  }
20270
20848
  try {
20271
- await walk((0, import_node_path23.resolve)(installPath));
20849
+ await walk((0, import_node_path24.resolve)(installPath));
20272
20850
  } catch (e) {
20273
20851
  logger.warn(`Error walking plugin path ${installPath}`, { error: String(e) });
20274
20852
  }
@@ -20305,7 +20883,7 @@ async function scanPlugin(plugin, options = {}) {
20305
20883
  }
20306
20884
  if (amsiClient) {
20307
20885
  try {
20308
- 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)}`;
20309
20887
  const amsiResult = await amsiClient.scanString("Plugin", scanName, content);
20310
20888
  if (amsiResult && (amsiResult.isDetected || amsiResult.isBlockedByAdmin)) {
20311
20889
  result.findings.push({
@@ -20313,7 +20891,7 @@ async function scanPlugin(plugin, options = {}) {
20313
20891
  title: `AMSI detection (result=${amsiResult.amsiResult})`,
20314
20892
  severity: "critical",
20315
20893
  artifact: content.slice(0, 200),
20316
- sourceFile: (0, import_node_path23.relative)(plugin.installPath, filePath)
20894
+ sourceFile: (0, import_node_path24.relative)(plugin.installPath, filePath)
20317
20895
  });
20318
20896
  }
20319
20897
  } catch {
@@ -20323,7 +20901,7 @@ async function scanPlugin(plugin, options = {}) {
20323
20901
  allUrls.push(...extractUrls(content));
20324
20902
  }
20325
20903
  if (checkFileHashes) {
20326
- const sha256 = (0, import_node_crypto10.createHash)("sha256").update(rawBytes).digest("hex");
20904
+ const sha256 = (0, import_node_crypto12.createHash)("sha256").update(rawBytes).digest("hex");
20327
20905
  const existing = hashToFiles.get(sha256);
20328
20906
  if (existing) {
20329
20907
  existing.push(filePath);
@@ -20366,7 +20944,7 @@ async function scanPlugin(plugin, options = {}) {
20366
20944
  title: `Malicious file (${fr.detectionNames.join(", ") || "unknown"})`,
20367
20945
  severity: "critical",
20368
20946
  artifact: fr.sha256,
20369
- sourceFile: (0, import_node_path23.relative)(plugin.installPath, filePath)
20947
+ sourceFile: (0, import_node_path24.relative)(plugin.installPath, filePath)
20370
20948
  });
20371
20949
  }
20372
20950
  }
@@ -20393,7 +20971,7 @@ function pushSkillFindingIfRisky(findings, plugin, skillId, folder, verdict) {
20393
20971
  title: verdict.summary?.trim() || (risk === "CRITICAL" ? "Malicious skill detected" : "Suspicious skill detected"),
20394
20972
  severity,
20395
20973
  artifact: skillId.slice(0, 16),
20396
- sourceFile: (0, import_node_path23.relative)(plugin.installPath, folder) || "."
20974
+ sourceFile: (0, import_node_path24.relative)(plugin.installPath, folder) || "."
20397
20975
  });
20398
20976
  }
20399
20977
  async function runSkillCheck(plugin, findings, logger, paths = {}) {
@@ -20431,7 +21009,7 @@ async function runSkillCheck(plugin, findings, logger, paths = {}) {
20431
21009
  if (verdict) {
20432
21010
  if (!recordedVerdicts.has(skillId)) {
20433
21011
  recordedVerdicts.set(skillId, recordAnalyzedSkillVerdict(verdictCache, skillId, verdict, {
20434
- skillName: (0, import_node_path23.basename)(folder),
21012
+ skillName: (0, import_node_path24.basename)(folder),
20435
21013
  agentRuntime: paths.agentRuntime,
20436
21014
  containerKey: plugin.key,
20437
21015
  loggingConfig: paths.loggingConfig
@@ -20483,12 +21061,12 @@ async function runSkillCheck(plugin, findings, logger, paths = {}) {
20483
21061
  // ../core/dist/session-start.js
20484
21062
  var import_node_child_process2 = require("node:child_process");
20485
21063
  var import_node_fs4 = require("node:fs");
20486
- var import_node_path25 = require("node:path");
21064
+ var import_node_path26 = require("node:path");
20487
21065
  init_config();
20488
21066
  init_file_utils();
20489
21067
 
20490
21068
  // ../core/dist/session-start-scan.js
20491
- var import_node_path24 = require("node:path");
21069
+ var import_node_path25 = require("node:path");
20492
21070
  init_config();
20493
21071
  init_types2();
20494
21072
  function fromCachedFinding(finding) {
@@ -20571,8 +21149,8 @@ async function scanAllPlugins(context, config2, plugins, exceptions, amsiClient,
20571
21149
  deniedByException: 0,
20572
21150
  findingsCount: 0
20573
21151
  };
20574
- const skillPendingPath = context.sageDirPath ? (0, import_node_path24.join)(context.sageDirPath, "skill_pending.json") : void 0;
20575
- 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;
20576
21154
  const cacheTtlMs = skillCacheTtlMs(config2);
20577
21155
  for (const plugin of plugins) {
20578
21156
  const denyMatch = findPluginDenyException(exceptions, plugin.key);
@@ -20738,6 +21316,16 @@ async function checkForUpdate(currentVersion, logger = nullLogger, timeoutMs = D
20738
21316
  async function runSessionStart(ctx) {
20739
21317
  const logger = ctx.logger ?? nullLogger;
20740
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
+ }
20741
21329
  pruneOrphanedTmpFiles(sageDirPath).catch(() => {
20742
21330
  });
20743
21331
  deployConfigDefaults(sageDirPath, logger).catch(() => {
@@ -20920,7 +21508,7 @@ async function maybeSpawnSkillUploadWorker(args) {
20920
21508
  });
20921
21509
  return;
20922
21510
  }
20923
- 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);
20924
21512
  const pendingCount = listPending(marker).length;
20925
21513
  if (pendingCount === 0) {
20926
21514
  args.logger.debug("Skill upload worker skipped", {
@@ -21059,19 +21647,19 @@ function canonicalizeToolName(map, rawName) {
21059
21647
  init_types2();
21060
21648
 
21061
21649
  // src/bundled-dirs.ts
21062
- var import_node_path26 = require("node:path");
21063
- 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, "..");
21064
21652
  function getBundledDataDirs() {
21065
21653
  return {
21066
- threatsDir: (0, import_node_path26.join)(packageRoot, "resources", "threats"),
21067
- 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")
21068
21656
  };
21069
21657
  }
21070
21658
  var cachedVersion;
21071
21659
  function getSageVersion() {
21072
21660
  if (cachedVersion) return cachedVersion;
21073
21661
  try {
21074
- 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"));
21075
21663
  cachedVersion = pkg.version ?? "0.0.0";
21076
21664
  } catch {
21077
21665
  cachedVersion = "0.0.0";
@@ -21080,16 +21668,16 @@ function getSageVersion() {
21080
21668
  }
21081
21669
 
21082
21670
  // src/startup-scan.ts
21083
- var import_node_path28 = require("node:path");
21671
+ var import_node_path29 = require("node:path");
21084
21672
 
21085
21673
  // src/plugin-discovery.ts
21086
- var import_promises12 = require("node:fs/promises");
21674
+ var import_promises14 = require("node:fs/promises");
21087
21675
  var import_node_os4 = require("node:os");
21088
- var import_node_path27 = require("node:path");
21089
- 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");
21090
21678
  var SKILL_ROOTS = [
21091
- { dir: (0, import_node_path27.join)(getHomeDir(), ".agents", "skills"), tag: "agents", scope: "personal" },
21092
- { 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" }
21093
21681
  ];
21094
21682
  async function discoverOpenClawPlugins(logger, extensionsDir = DEFAULT_EXTENSIONS_DIR, branding2 = defaultBranding, skillRoots = SKILL_ROOTS) {
21095
21683
  logger.debug(`${branding2.name} plugin discovery: scanning extensions directory`, {
@@ -21098,23 +21686,23 @@ async function discoverOpenClawPlugins(logger, extensionsDir = DEFAULT_EXTENSION
21098
21686
  const plugins = [];
21099
21687
  let entries;
21100
21688
  try {
21101
- entries = await (0, import_promises12.readdir)(extensionsDir);
21689
+ entries = await (0, import_promises14.readdir)(extensionsDir);
21102
21690
  } catch {
21103
21691
  logger.debug("OpenClaw extensions directory not found", { path: extensionsDir });
21104
21692
  entries = [];
21105
21693
  }
21106
21694
  for (const entry of entries) {
21107
- const extDir = (0, import_node_path27.join)(extensionsDir, entry);
21695
+ const extDir = (0, import_node_path28.join)(extensionsDir, entry);
21108
21696
  let stats;
21109
21697
  try {
21110
- stats = await (0, import_promises12.stat)(extDir);
21698
+ stats = await (0, import_promises14.stat)(extDir);
21111
21699
  if (!stats.isDirectory()) continue;
21112
21700
  } catch {
21113
21701
  continue;
21114
21702
  }
21115
21703
  let pkg;
21116
21704
  try {
21117
- const pkgPath = (0, import_node_path27.join)(extDir, "package.json");
21705
+ const pkgPath = (0, import_node_path28.join)(extDir, "package.json");
21118
21706
  const raw = await getFileContent(pkgPath);
21119
21707
  pkg = JSON.parse(raw);
21120
21708
  } catch {
@@ -21161,8 +21749,8 @@ function createOpenClawScanHandler(logger, context, branding2 = defaultBranding,
21161
21749
  // No onNotices: OpenClaw delivers the one-time notice from
21162
21750
  // `before_prompt_build` (a different process/turn than this scan) via
21163
21751
  // takePendingNotices, so the scan-time callback would never fire.
21164
- modelDownloadWorkerPath: (0, import_node_path28.resolve)(__dirname, "model-download-worker.cjs"),
21165
- 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"),
21166
21754
  // Don't commit the "shown" flag here — commit happens at delivery.
21167
21755
  commitUploadNotice: false,
21168
21756
  announceCleanScans