@gendigital/sage-openclaw 0.13.0 → 0.14.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.
@@ -46,6 +46,39 @@ function getProcEnv() {
46
46
  function getHomeDir() {
47
47
  return getProcEnv().HOME || (0, import_node_os.homedir)();
48
48
  }
49
+ async function renameWithRetry(from, to, attempts = 5) {
50
+ if (process.platform !== "win32") {
51
+ await fsPromises.rename(from, to);
52
+ return;
53
+ }
54
+ for (let i = 0; i < attempts; i++) {
55
+ try {
56
+ await fsPromises.rename(from, to);
57
+ return;
58
+ } catch (err) {
59
+ const code = err.code;
60
+ const transient = code === "EPERM" || code === "EACCES" || code === "EBUSY";
61
+ if (!transient || i === attempts - 1)
62
+ throw err;
63
+ await new Promise((resolve2) => setTimeout(resolve2, 10 * 2 ** i));
64
+ }
65
+ }
66
+ }
67
+ async function atomicWriteJson(path2, data) {
68
+ await fsPromises.mkdir((0, import_node_path.dirname)(path2), { recursive: true });
69
+ const tmp = `${path2}.${(0, import_node_crypto.randomBytes)(6).toString("hex")}.tmp`;
70
+ try {
71
+ await fsPromises.writeFile(tmp, `${JSON.stringify(data, null, 2)}
72
+ `, { mode: 384 });
73
+ await renameWithRetry(tmp, path2);
74
+ } catch (err) {
75
+ try {
76
+ await fsPromises.unlink(tmp);
77
+ } catch {
78
+ }
79
+ throw err;
80
+ }
81
+ }
49
82
  async function removeStaleFileLock(lockPath, staleAgeMs) {
50
83
  try {
51
84
  const s = await fsPromises.stat(lockPath);
@@ -86,13 +119,15 @@ async function withFileLock(filePath, callback) {
86
119
  await release();
87
120
  }
88
121
  }
89
- var fs, fsPromises, import_node_os, import_promises, name1, name2;
122
+ var import_node_crypto, fs, fsPromises, import_node_os, import_node_path, import_promises, name1, name2;
90
123
  var init_file_utils = __esm({
91
124
  "../core/dist/file-utils.js"() {
92
125
  "use strict";
126
+ import_node_crypto = require("node:crypto");
93
127
  fs = __toESM(require("node:fs"), 1);
94
128
  fsPromises = __toESM(require("node:fs/promises"), 1);
95
129
  import_node_os = require("node:os");
130
+ import_node_path = require("node:path");
96
131
  import_promises = require("node:timers/promises");
97
132
  name1 = "read";
98
133
  name2 = "File";
@@ -4346,38 +4381,48 @@ var init_types2 = __esm({
4346
4381
  });
4347
4382
 
4348
4383
  // ../core/dist/config.js
4384
+ function skillCacheTtlMs(config2) {
4385
+ return Math.max(1, config2.skill_check.cache_ttl_days) * MS_PER_DAY;
4386
+ }
4387
+ function isFreshTimestamp(ts, ttlMs, now = Date.now()) {
4388
+ if (Number.isNaN(ts))
4389
+ return false;
4390
+ if (ts - now > CLOCK_SKEW_TOLERANCE_MS)
4391
+ return false;
4392
+ return now - ts < ttlMs;
4393
+ }
4349
4394
  function resolvedSageDir() {
4350
4395
  return resolvePath(SAGE_DIR);
4351
4396
  }
4352
4397
  function defaultConfigPath() {
4353
- return (0, import_node_path.join)(resolvedSageDir(), "config.json");
4398
+ return (0, import_node_path2.join)(resolvedSageDir(), "config.json");
4354
4399
  }
4355
4400
  function defaultCachePath() {
4356
- return (0, import_node_path.join)(resolvedSageDir(), "cache.json");
4401
+ return (0, import_node_path2.join)(resolvedSageDir(), "cache.json");
4357
4402
  }
4358
4403
  function defaultExceptionsPath() {
4359
- return (0, import_node_path.join)(resolvedSageDir(), "exceptions.json");
4404
+ return (0, import_node_path2.join)(resolvedSageDir(), "exceptions.json");
4360
4405
  }
4361
4406
  function defaultAuditPath() {
4362
- return (0, import_node_path.join)(resolvedSageDir(), "audit.jsonl");
4407
+ return (0, import_node_path2.join)(resolvedSageDir(), "audit.jsonl");
4363
4408
  }
4364
4409
  function defaultOperationalLogPath() {
4365
- return (0, import_node_path.join)(resolvedSageDir(), "operational.jsonl");
4410
+ return (0, import_node_path2.join)(resolvedSageDir(), "operational.jsonl");
4366
4411
  }
4367
4412
  function resolvePath(pathStr) {
4368
4413
  if (pathStr.startsWith("~/") || pathStr === "~") {
4369
4414
  const home = getHomeDir();
4370
- return (0, import_node_path.join)(home, pathStr.slice(1));
4415
+ return (0, import_node_path2.join)(home, pathStr.slice(1));
4371
4416
  }
4372
4417
  return pathStr;
4373
4418
  }
4374
4419
  function isWithinDirectory(baseDir, targetPath) {
4375
- const rel = (0, import_node_path.relative)(baseDir, targetPath);
4420
+ const rel = (0, import_node_path2.relative)(baseDir, targetPath);
4376
4421
  if (rel === "")
4377
4422
  return true;
4378
- if ((0, import_node_path.isAbsolute)(rel))
4423
+ if ((0, import_node_path2.isAbsolute)(rel))
4379
4424
  return false;
4380
- return rel !== ".." && !rel.startsWith(`..${import_node_path.sep}`);
4425
+ return rel !== ".." && !rel.startsWith(`..${import_node_path2.sep}`);
4381
4426
  }
4382
4427
  function normalizeStateFilePath(configuredPath, fallbackPath, field, logger2) {
4383
4428
  const sageDir = resolvedSageDir();
@@ -4390,7 +4435,7 @@ function normalizeStateFilePath(configuredPath, fallbackPath, field, logger2) {
4390
4435
  return fallbackPath;
4391
4436
  }
4392
4437
  const expanded = resolvePath(trimmed);
4393
- const resolved = (0, import_node_path.isAbsolute)(expanded) ? (0, import_node_path.resolve)(expanded) : (0, import_node_path.resolve)(sageDir, expanded);
4438
+ const resolved = (0, import_node_path2.isAbsolute)(expanded) ? (0, import_node_path2.resolve)(expanded) : (0, import_node_path2.resolve)(sageDir, expanded);
4394
4439
  if (isWithinDirectory(sageDir, resolved)) {
4395
4440
  if (resolved === sageDir) {
4396
4441
  logger2.warn(`Config ${field}.path must point to a file; using default`, {
@@ -4489,11 +4534,11 @@ async function loadConfig(configPath, logger2 = nullLogger) {
4489
4534
  return defaultConfig(logger2);
4490
4535
  }
4491
4536
  }
4492
- var import_node_path, SAGE_DIR, MS_PER_DAY, CLOCK_SKEW_TOLERANCE_MS, BRAND_KEY_RE;
4537
+ var import_node_path2, SAGE_DIR, MS_PER_DAY, CLOCK_SKEW_TOLERANCE_MS, BRAND_KEY_RE;
4493
4538
  var init_config = __esm({
4494
4539
  "../core/dist/config.js"() {
4495
4540
  "use strict";
4496
- import_node_path = require("node:path");
4541
+ import_node_path2 = require("node:path");
4497
4542
  init_file_utils();
4498
4543
  init_types2();
4499
4544
  SAGE_DIR = "~/.sage";
@@ -20548,12 +20593,53 @@ function safeTruncate(value, maxLen) {
20548
20593
  return value.slice(0, cutIndex);
20549
20594
  }
20550
20595
 
20596
+ // ../core/dist/detection-names.js
20597
+ var REPORTING_ENGINE_SHORTHANDS = {
20598
+ package: "sgpk",
20599
+ amsi: "sgam",
20600
+ heuristics: "sghe",
20601
+ ml: "sgml",
20602
+ skill: "skan"
20603
+ };
20604
+ var REPORTING_DETECTION_SUFFIX = "sage";
20605
+ function formatReportingDetectionName(canonicalName, engine, optionalData = "") {
20606
+ const engineMetadata = optionalData ? `${REPORTING_ENGINE_SHORTHANDS[engine]}:${optionalData}` : REPORTING_ENGINE_SHORTHANDS[engine];
20607
+ return `${canonicalName}|${engineMetadata}|${REPORTING_DETECTION_SUFFIX}`;
20608
+ }
20609
+ var SKILL_DETECTION_NAMES = {
20610
+ HIGH: "Other:SageSuspiciousSkill-A [Susp]",
20611
+ CRITICAL: "Other:SageMaliciousSkill-A [Trj]"
20612
+ };
20613
+ function skillDetectionName(verdict) {
20614
+ return SKILL_DETECTION_NAMES[verdict.toUpperCase()];
20615
+ }
20616
+ function isSkillId(value) {
20617
+ return /^[0-9a-f]{64}$/i.test(value);
20618
+ }
20619
+ function skillReportingDetectionName(verdict, skillId) {
20620
+ const canonicalName = skillDetectionName(verdict);
20621
+ return canonicalName && isSkillId(skillId) ? formatReportingDetectionName(canonicalName, "skill", skillId.toLowerCase()) : void 0;
20622
+ }
20623
+ function buildSkillCheckSignal(skillId, analyzerVerdict, jobId) {
20624
+ const normalizedSkillId = skillId.toLowerCase();
20625
+ const normalizedVerdict = analyzerVerdict.toUpperCase();
20626
+ const detectionName = skillReportingDetectionName(normalizedVerdict, normalizedSkillId);
20627
+ if (!detectionName)
20628
+ return void 0;
20629
+ return {
20630
+ detection_name: detectionName,
20631
+ skill_id: normalizedSkillId,
20632
+ ...jobId ? { job_id: jobId } : {},
20633
+ analyzer_verdict: normalizedVerdict
20634
+ };
20635
+ }
20636
+
20551
20637
  // ../core/dist/audit-log.js
20552
20638
  init_file_utils();
20553
20639
 
20554
20640
  // ../core/dist/jsonl-log-writer.js
20555
20641
  var import_promises2 = require("node:fs/promises");
20556
- var import_node_path2 = require("node:path");
20642
+ var import_node_path3 = require("node:path");
20557
20643
  init_config();
20558
20644
  init_file_utils();
20559
20645
  var writeQueues = /* @__PURE__ */ new Map();
@@ -20590,7 +20676,7 @@ async function rotateIfNeeded(filePath, maxBytes, maxFiles) {
20590
20676
  });
20591
20677
  }
20592
20678
  async function appendJsonlEntryNow(path2, config2, entry) {
20593
- await (0, import_promises2.mkdir)((0, import_node_path2.dirname)(path2), { recursive: true });
20679
+ await (0, import_promises2.mkdir)((0, import_node_path3.dirname)(path2), { recursive: true });
20594
20680
  await rotateIfNeeded(path2, config2.max_bytes, config2.max_files);
20595
20681
  await (0, import_promises2.appendFile)(path2, `${JSON.stringify(entry)}
20596
20682
  `);
@@ -20784,15 +20870,15 @@ init_types2();
20784
20870
  init_types2();
20785
20871
 
20786
20872
  // ../core/dist/version.js
20787
- var import_node_path3 = require("node:path");
20873
+ var import_node_path4 = require("node:path");
20788
20874
  var import_node_url = require("node:url");
20789
20875
  init_file_utils();
20790
20876
  var import_meta = {};
20791
20877
  function resolveVersion() {
20792
20878
  if (true)
20793
- return "0.13.0";
20879
+ return "0.14.0";
20794
20880
  try {
20795
- const pkgPath = (0, import_node_path3.join)((0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(import_meta.url)), "..", "package.json");
20881
+ const pkgPath = (0, import_node_path4.join)((0, import_node_path4.dirname)((0, import_node_url.fileURLToPath)(import_meta.url)), "..", "package.json");
20796
20882
  const pkg = JSON.parse(getFileContentSync(pkgPath));
20797
20883
  if (typeof pkg.version === "string")
20798
20884
  return pkg.version;
@@ -20913,7 +20999,7 @@ init_types2();
20913
20999
  // ../core/dist/extended-info.js
20914
21000
  var import_promises3 = require("node:fs/promises");
20915
21001
  var import_node_os2 = require("node:os");
20916
- var import_node_path4 = require("node:path");
21002
+ var import_node_path5 = require("node:path");
20917
21003
  init_file_utils();
20918
21004
  init_types2();
20919
21005
  var EXTENDED_INFO_FILE_MAX_BYTES = 1024;
@@ -20979,8 +21065,8 @@ function sanitizeDocument(parsed, logger2) {
20979
21065
  return out;
20980
21066
  }
20981
21067
  async function loadExtendedInfo(sageDirPath, logger2 = nullLogger) {
20982
- const sageDir = sageDirPath ?? (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".sage");
20983
- const filePath = (0, import_node_path4.join)(sageDir, EXTENDED_INFO_FILENAME);
21068
+ const sageDir = sageDirPath ?? (0, import_node_path5.join)((0, import_node_os2.homedir)(), ".sage");
21069
+ const filePath = (0, import_node_path5.join)(sageDir, EXTENDED_INFO_FILENAME);
20984
21070
  const cached2 = cache.get(filePath);
20985
21071
  if (cached2)
20986
21072
  return cached2.value;
@@ -21078,14 +21164,14 @@ function mergeExtendedInfo(envelope, extendedInfo) {
21078
21164
  }
21079
21165
 
21080
21166
  // ../core/dist/installation-id.js
21081
- var import_node_crypto = require("node:crypto");
21167
+ var import_node_crypto2 = require("node:crypto");
21082
21168
  var import_promises4 = require("node:fs/promises");
21083
- var import_node_path5 = require("node:path");
21169
+ var import_node_path6 = require("node:path");
21084
21170
  init_config();
21085
21171
  init_file_utils();
21086
21172
  async function getInstallationId(sageDirPath) {
21087
21173
  const sageDir = sageDirPath ?? resolvePath("~/.sage");
21088
- const idPath = (0, import_node_path5.join)(sageDir, "installation-id");
21174
+ const idPath = (0, import_node_path6.join)(sageDir, "installation-id");
21089
21175
  let fileExists = false;
21090
21176
  try {
21091
21177
  const existing = await getFileContent(idPath, "utf-8");
@@ -21096,7 +21182,7 @@ async function getInstallationId(sageDirPath) {
21096
21182
  } catch {
21097
21183
  }
21098
21184
  try {
21099
- const id = (0, import_node_crypto.randomUUID)();
21185
+ const id = (0, import_node_crypto2.randomUUID)();
21100
21186
  await (0, import_promises4.mkdir)(sageDir, { recursive: true, mode: 448 });
21101
21187
  await (0, import_promises4.writeFile)(idPath, id, { encoding: "utf-8", mode: 384, flag: fileExists ? "w" : "wx" });
21102
21188
  return id;
@@ -21311,11 +21397,11 @@ function createOperationalLogger(config2, runtime) {
21311
21397
  }
21312
21398
 
21313
21399
  // ../core/dist/plugin-scan-cache.js
21314
- var import_node_path6 = require("node:path");
21400
+ var import_node_path7 = require("node:path");
21315
21401
  init_config();
21316
21402
  init_file_utils();
21317
21403
  init_types2();
21318
- var DEFAULT_CACHE_PATH = (0, import_node_path6.join)(getHomeDir(), ".sage", "plugin_scan_cache.json");
21404
+ var DEFAULT_CACHE_PATH = (0, import_node_path7.join)(getHomeDir(), ".sage", "plugin_scan_cache.json");
21319
21405
 
21320
21406
  // ../core/dist/plugin-scanner.js
21321
21407
  init_config();
@@ -21331,9 +21417,119 @@ init_types2();
21331
21417
  var PENDING_TTL_MS = 60 * 60 * 1e3;
21332
21418
 
21333
21419
  // ../core/dist/skill-verdict-cache.js
21420
+ var import_node_path8 = require("node:path");
21334
21421
  init_config();
21335
21422
  init_file_utils();
21336
21423
  init_types2();
21424
+ var SCHEMA_VERSION = 1;
21425
+ var DEFAULT_VERDICT_TTL_MS = MS_PER_DAY;
21426
+ function parseSources(raw) {
21427
+ if (!Array.isArray(raw))
21428
+ return void 0;
21429
+ const sources = [];
21430
+ for (const item of raw) {
21431
+ if (!item || typeof item !== "object")
21432
+ continue;
21433
+ const entry = item;
21434
+ const source = {
21435
+ agentRuntime: typeof entry.agent_runtime === "string" ? entry.agent_runtime : void 0,
21436
+ containerKey: typeof entry.container_key === "string" ? entry.container_key : void 0
21437
+ };
21438
+ if (source.agentRuntime || source.containerKey)
21439
+ sources.push(source);
21440
+ }
21441
+ return sources.length > 0 ? sources : void 0;
21442
+ }
21443
+ function defaultCachePath2() {
21444
+ return (0, import_node_path8.join)(resolvePath(SAGE_DIR), "skill_verdict_cache.json");
21445
+ }
21446
+ async function loadSkillVerdictCacheInternal(cachePath, ttlMs, logger2, pruneOnLoad) {
21447
+ let raw;
21448
+ try {
21449
+ raw = await getFileContent(cachePath);
21450
+ } catch {
21451
+ return { entries: {} };
21452
+ }
21453
+ try {
21454
+ const data = JSON.parse(raw);
21455
+ const rawEntries = data.entries ?? {};
21456
+ const now = Date.now();
21457
+ const entries = {};
21458
+ let pruned = 0;
21459
+ for (const [skillId, entry] of Object.entries(rawEntries)) {
21460
+ const analyzedAt = entry.analyzed_at;
21461
+ if (!analyzedAt)
21462
+ continue;
21463
+ const ts = Date.parse(analyzedAt);
21464
+ if (!isFreshTimestamp(ts, ttlMs, now)) {
21465
+ pruned++;
21466
+ continue;
21467
+ }
21468
+ entries[skillId] = {
21469
+ entryId: typeof entry.entry_id === "string" ? entry.entry_id : void 0,
21470
+ jobId: typeof entry.job_id === "string" ? entry.job_id : void 0,
21471
+ verdict: typeof entry.verdict === "string" ? entry.verdict : void 0,
21472
+ summary: typeof entry.summary === "string" ? entry.summary : void 0,
21473
+ skillName: typeof entry.skill_name === "string" ? entry.skill_name : void 0,
21474
+ sources: parseSources(entry.sources),
21475
+ analyzedAt
21476
+ };
21477
+ }
21478
+ const cache2 = { entries };
21479
+ if (pruneOnLoad && pruned > 0) {
21480
+ logger2.debug(`Pruned ${pruned} stale verdict(s) from cache`, { cachePath });
21481
+ await saveSkillVerdictCache(cache2, cachePath, logger2);
21482
+ }
21483
+ return cache2;
21484
+ } catch (e) {
21485
+ logger2.warn(`Failed to load verdict cache from ${cachePath}`, { error: String(e) });
21486
+ return { entries: {} };
21487
+ }
21488
+ }
21489
+ async function loadSkillVerdictCacheReadOnly(cachePath = defaultCachePath2(), ttlMs = DEFAULT_VERDICT_TTL_MS, logger2 = nullLogger) {
21490
+ return loadSkillVerdictCacheInternal(cachePath, ttlMs, logger2, false);
21491
+ }
21492
+ async function saveSkillVerdictCache(cache2, cachePath = defaultCachePath2(), logger2 = nullLogger) {
21493
+ try {
21494
+ const data = {
21495
+ schema_version: SCHEMA_VERSION,
21496
+ entries: Object.fromEntries(Object.entries(cache2.entries).map(([skillId, v]) => [
21497
+ skillId,
21498
+ {
21499
+ entry_id: v.entryId,
21500
+ job_id: v.jobId,
21501
+ verdict: v.verdict,
21502
+ summary: v.summary,
21503
+ skill_name: v.skillName,
21504
+ sources: v.sources?.map((s) => ({
21505
+ agent_runtime: s.agentRuntime,
21506
+ container_key: s.containerKey
21507
+ })),
21508
+ analyzed_at: v.analyzedAt
21509
+ }
21510
+ ]))
21511
+ };
21512
+ await atomicWriteJson(cachePath, data);
21513
+ return true;
21514
+ } catch (e) {
21515
+ logger2.warn(`Failed to save verdict cache to ${cachePath}`, { error: String(e) });
21516
+ return false;
21517
+ }
21518
+ }
21519
+
21520
+ // ../core/dist/skill-verdict-record.js
21521
+ var import_node_crypto3 = require("node:crypto");
21522
+ var SKILL_ENTRY_ID_NAMESPACE = Buffer.from("75bba79a376142a7bacbf95d791126d3", "hex");
21523
+ function legacySkillEntryId(skillId, analyzedAt) {
21524
+ const bytes = (0, import_node_crypto3.createHash)("sha1").update(SKILL_ENTRY_ID_NAMESPACE).update(`${skillId.toLowerCase()}:${analyzedAt}`).digest().subarray(0, 16);
21525
+ bytes[6] = (bytes[6] ?? 0) & 15 | 80;
21526
+ bytes[8] = (bytes[8] ?? 0) & 63 | 128;
21527
+ const hex = bytes.toString("hex");
21528
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
21529
+ }
21530
+ function resolveSkillVerdictEntryId(skillId, entry) {
21531
+ return entry.entryId ?? legacySkillEntryId(skillId, entry.analyzedAt);
21532
+ }
21337
21533
 
21338
21534
  // ../core/dist/plugin-scanner.js
21339
21535
  init_types2();
@@ -21341,12 +21537,12 @@ var MAX_FILE_SIZE = 512 * 1024;
21341
21537
 
21342
21538
  // ../core/dist/product-version.js
21343
21539
  var import_node_fs = require("node:fs");
21344
- var import_node_path7 = __toESM(require("node:path"), 1);
21540
+ var import_node_path9 = __toESM(require("node:path"), 1);
21345
21541
  function readProductJsonVersion(appRoot) {
21346
21542
  if (!appRoot)
21347
21543
  return "unknown";
21348
21544
  try {
21349
- const productJsonPath = import_node_path7.default.join(appRoot, "product.json");
21545
+ const productJsonPath = import_node_path9.default.join(appRoot, "product.json");
21350
21546
  const raw = (0, import_node_fs.readFileSync)(productJsonPath, "utf8");
21351
21547
  const parsed = JSON.parse(raw);
21352
21548
  const version2 = parsed.version;
@@ -31409,7 +31605,7 @@ var StdioServerTransport = class {
31409
31605
  };
31410
31606
 
31411
31607
  // ../mcp/dist/tools/false-positive.js
31412
- var import_node_crypto2 = require("node:crypto");
31608
+ var import_node_crypto4 = require("node:crypto");
31413
31609
  init_zod();
31414
31610
 
31415
31611
  // ../mcp/dist/tools/utils.js
@@ -31447,12 +31643,71 @@ function isRuntimeVerdictEntry(v) {
31447
31643
  return false;
31448
31644
  return v.type === "runtime_verdict";
31449
31645
  }
31646
+ function isAnalyzedSkillVerdictEntry(v) {
31647
+ if (!v || typeof v !== "object" || Array.isArray(v))
31648
+ return false;
31649
+ const entry = v;
31650
+ return entry.type === "skill_verdict" && entry.status === "analyzed" && isSkillId(asString(entry.skill_id) ?? "");
31651
+ }
31652
+ function isReportableSkillVerdictEntry(v) {
31653
+ if (!isAnalyzedSkillVerdictEntry(v))
31654
+ return false;
31655
+ const entry = v;
31656
+ const verdict = asString(entry.verdict)?.toUpperCase();
31657
+ return (verdict === "HIGH" || verdict === "CRITICAL") && asString(entry.entry_id) !== void 0;
31658
+ }
31659
+ function latestReportableSkillVerdicts(entries, ttlMs, now = Date.now()) {
31660
+ const latestBySkillId = /* @__PURE__ */ new Map();
31661
+ for (const entry of entries) {
31662
+ if (!isAnalyzedSkillVerdictEntry(entry))
31663
+ continue;
31664
+ const timestamp = Date.parse(asString(entry.timestamp) ?? "");
31665
+ if (!isFreshTimestamp(timestamp, ttlMs, now))
31666
+ continue;
31667
+ const normalizedSkillId = entry.skill_id.toLowerCase();
31668
+ const current = latestBySkillId.get(normalizedSkillId);
31669
+ if (current && timestamp < current.timestamp)
31670
+ continue;
31671
+ latestBySkillId.delete(normalizedSkillId);
31672
+ latestBySkillId.set(normalizedSkillId, { entry, timestamp });
31673
+ }
31674
+ return [...latestBySkillId.values()].map(({ entry }) => entry).filter(isReportableSkillVerdictEntry);
31675
+ }
31676
+ async function cachedSkillVerdictEntries(config2, logger2) {
31677
+ const cache2 = await loadSkillVerdictCacheReadOnly(void 0, skillCacheTtlMs(config2), logger2);
31678
+ return Object.entries(cache2.entries).flatMap(([skillId, entry]) => {
31679
+ if (!isSkillId(skillId))
31680
+ return [];
31681
+ if (!entry.verdict)
31682
+ return [];
31683
+ const normalizedSkillId = skillId.toLowerCase();
31684
+ const analyzerVerdict = entry.verdict.toUpperCase();
31685
+ const signal = buildSkillCheckSignal(normalizedSkillId, analyzerVerdict, entry.jobId);
31686
+ return [
31687
+ {
31688
+ type: "skill_verdict",
31689
+ entry_id: resolveSkillVerdictEntryId(normalizedSkillId, entry),
31690
+ timestamp: entry.analyzedAt,
31691
+ agent_runtime: entry.sources?.[0]?.agentRuntime,
31692
+ skill_id: normalizedSkillId,
31693
+ job_id: entry.jobId,
31694
+ status: "analyzed",
31695
+ verdict: analyzerVerdict,
31696
+ signals: signal ? { skill_checks: [signal] } : void 0
31697
+ }
31698
+ ];
31699
+ });
31700
+ }
31450
31701
  function getConversationId(entry) {
31451
31702
  return asString(entry.conversation_id) ?? asString(entry.session_id);
31452
31703
  }
31453
31704
  function getEntryId(entry) {
31454
31705
  return asString(entry.entry_id);
31455
31706
  }
31707
+ function getEntryTimestamp(entry) {
31708
+ const timestamp = Date.parse(asString(entry.timestamp) ?? "");
31709
+ return Number.isFinite(timestamp) ? timestamp : 0;
31710
+ }
31456
31711
  function asHookType(value) {
31457
31712
  const s = asString(value);
31458
31713
  if (s === "PreToolUse" || s === "PostToolUse" || s === "SessionStart" || s === "GatewayStart" || s === "BeforeAgentStart" || s === "MessagesTransform") {
@@ -31473,9 +31728,10 @@ async function postMultipart(endpoint, payload, timeoutSeconds) {
31473
31728
  const bodyText = await res.text().catch(() => "");
31474
31729
  return { ok: res.ok, status: res.status, bodyText };
31475
31730
  }
31731
+ var MAX_AUDIT_SCAN_ENTRIES = 5e3;
31476
31732
  var ListInputSchema = external_exports.object({
31477
31733
  conversation_id: external_exports.string().optional().describe("Conversation id to filter audit entries; if omitted, uses the most recent one."),
31478
- limit: external_exports.number().int().min(1).max(5e3).optional().describe("Max entries to scan and return.")
31734
+ limit: external_exports.number().int().min(1).max(MAX_AUDIT_SCAN_ENTRIES).optional().describe("Max merged entries to return.")
31479
31735
  });
31480
31736
  var MAX_IMPLICIT_ENTRIES = 3;
31481
31737
  var MAX_EXPLICIT_ENTRIES = 10;
@@ -31484,10 +31740,10 @@ var ReportInputSchema = external_exports.object({
31484
31740
  description: external_exports.string().min(1).describe("Short description of what is a false positive."),
31485
31741
  reasoning: external_exports.string().min(1).describe("Reasoning for why this should be treated as a false positive."),
31486
31742
  agent_runtime_version: external_exports.string().optional().describe("Optional agent runtime version override (Cursor/VS Code/Claude version). If omitted, the server resolves the host version from SAGE_APP_ROOT (the application root injected by the Sage extension at MCP-server install time, used to read product.json), then falls back to SAGE_AGENT_RUNTIME_VERSION env, then 'unknown'."),
31487
- entry_ids: external_exports.array(external_exports.string()).optional().describe(`REQUIRED in normal use: list of specific audit entry_ids to report as false positives. ALWAYS call sage_list_audit_entries first to inspect the audit log and identify the exact deny/ask verdict the user considers a false positive, then pass its entry_id here. Up to ${MAX_EXPLICIT_ENTRIES} entry_ids may be provided per call. Omitting this parameter is a fallback that submits up to ${MAX_IMPLICIT_ENTRIES} most recent deny/ask entries for the conversation, which may include irrelevant verdicts and should only be used when entry_id discovery is impossible.`),
31743
+ entry_ids: external_exports.array(external_exports.string()).max(MAX_EXPLICIT_ENTRIES).optional().describe(`REQUIRED in normal use: list of specific audit entry_ids to report as false positives. ALWAYS call sage_list_audit_entries first to inspect the audit log and identify the exact deny/ask verdict the user considers a false positive, then pass its entry_id here. Up to ${MAX_EXPLICIT_ENTRIES} entry_ids may be provided per call. Omitting this parameter is a fallback that submits up to ${MAX_IMPLICIT_ENTRIES} most recent deny/ask entries for the conversation, which may include irrelevant verdicts and should only be used when entry_id discovery is impossible.`),
31488
31744
  dry_run: external_exports.boolean().optional().describe("If true, do not POST; just show the payload.")
31489
31745
  });
31490
- function parseAuditSignals(raw, options = {}) {
31746
+ function parseRuntimeAuditSignals(raw, options = {}) {
31491
31747
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
31492
31748
  return {};
31493
31749
  const includePiContentSnippet = options.includePiContentSnippet ?? true;
@@ -31596,7 +31852,7 @@ function registerFalsePositiveTools(server, opts) {
31596
31852
  const branding = opts.branding ?? defaultBranding;
31597
31853
  server.registerTool("sage_list_audit_entries", {
31598
31854
  title: `${branding.name}: List Audit Entries`,
31599
- description: `List recent ${branding.name} audit log entries, optionally scoped to a conversation id. Useful for selecting entry_ids to report as false positives.`,
31855
+ description: `List recent ${branding.name} runtime and Skill Analyzer audit entries. Runtime entries are optionally scoped to a conversation id; skill entries are global because analysis can finish outside the originating conversation. Useful for selecting entry_ids to report as false positives.`,
31600
31856
  inputSchema: ListInputSchema
31601
31857
  }, async ({ conversation_id, limit }) => {
31602
31858
  const complete = (result, data = {}) => {
@@ -31614,33 +31870,59 @@ function registerFalsePositiveTools(server, opts) {
31614
31870
  });
31615
31871
  try {
31616
31872
  const config2 = await loadConfig(void 0, logger2);
31617
- const scanLimit = limit ?? 500;
31618
- const entries = await getRecentEntries(config2.logging, scanLimit);
31873
+ const outputLimit = limit ?? 500;
31874
+ const entries = await getRecentEntries(config2.logging, MAX_AUDIT_SCAN_ENTRIES);
31619
31875
  const runtime = entries.filter(isRuntimeVerdictEntry);
31876
+ const cachedSkills = await cachedSkillVerdictEntries(config2, logger2);
31877
+ const skills = latestReportableSkillVerdicts([...entries, ...cachedSkills], skillCacheTtlMs(config2));
31620
31878
  const inferredConversationId = conversation_id ?? getConversationId(runtime.slice().reverse().find((e) => !!getConversationId(e)) ?? {});
31621
- if (!inferredConversationId) {
31622
- complete("skipped", { skippedReason: "no_runtime_entries", entriesReturned: 0 });
31623
- return textResult("No runtime_verdict entries found in the audit log.", true);
31624
- }
31625
- const filtered = runtime.filter((e) => getConversationId(e) === inferredConversationId);
31626
- const out = filtered.map((e) => ({
31627
- entry_id: getEntryId(e),
31628
- timestamp: asString(e.timestamp),
31629
- conversation_id: inferredConversationId,
31630
- agent_runtime: asString(e.agent_runtime),
31631
- tool_name: asString(e.tool_name),
31632
- tool_input_summary: asString(e.tool_input_summary),
31633
- verdict: asString(e.verdict),
31634
- severity: asString(e.severity),
31635
- source: asString(e.source),
31636
- user_override: e.user_override,
31637
- // PI snippets are untrusted fetched content. Exclude their raw
31638
- // text—including the generic top-level content_snippet - from this
31639
- // model-visible response. Retain it only for the backend-only
31640
- // false-positive report below.
31641
- signals: parseAuditSignals(e.signals, { includePiContentSnippet: false }),
31642
- content: readContent(e)
31643
- }));
31879
+ if (!inferredConversationId && skills.length === 0) {
31880
+ complete("skipped", { skippedReason: "no_reportable_entries", entriesReturned: 0 });
31881
+ return textResult("No reportable runtime or skill verdict entries found.", true);
31882
+ }
31883
+ const filteredRuntime = inferredConversationId ? runtime.filter((e) => getConversationId(e) === inferredConversationId) : [];
31884
+ const selected = [
31885
+ ...filteredRuntime,
31886
+ ...skills
31887
+ ].sort((a, b) => getEntryTimestamp(a) - getEntryTimestamp(b)).slice(-outputLimit);
31888
+ const out = selected.map((entry) => {
31889
+ if (isReportableSkillVerdictEntry(entry)) {
31890
+ const skillId = entry.skill_id;
31891
+ const analyzerVerdict = asString(entry.verdict)?.toUpperCase() ?? "";
31892
+ const signal = buildSkillCheckSignal(skillId, analyzerVerdict, asString(entry.job_id));
31893
+ return {
31894
+ type: "skill_verdict",
31895
+ entry_id: getEntryId(entry),
31896
+ timestamp: asString(entry.timestamp),
31897
+ agent_runtime: asString(entry.agent_runtime),
31898
+ skill_id: skillId.toLowerCase(),
31899
+ job_id: asString(entry.job_id),
31900
+ status: asString(entry.status),
31901
+ analyzer_verdict: analyzerVerdict,
31902
+ signals: signal ? { skill_checks: [signal] } : {}
31903
+ };
31904
+ }
31905
+ const runtimeEntry = entry;
31906
+ return {
31907
+ type: "runtime_verdict",
31908
+ entry_id: getEntryId(runtimeEntry),
31909
+ timestamp: asString(runtimeEntry.timestamp),
31910
+ conversation_id: inferredConversationId,
31911
+ agent_runtime: asString(runtimeEntry.agent_runtime),
31912
+ tool_name: asString(runtimeEntry.tool_name),
31913
+ tool_input_summary: asString(runtimeEntry.tool_input_summary),
31914
+ verdict: asString(runtimeEntry.verdict),
31915
+ severity: asString(runtimeEntry.severity),
31916
+ source: asString(runtimeEntry.source),
31917
+ user_override: runtimeEntry.user_override,
31918
+ // PI snippets are untrusted fetched content. Exclude their raw
31919
+ // text from this model-visible response.
31920
+ signals: parseRuntimeAuditSignals(runtimeEntry.signals, {
31921
+ includePiContentSnippet: false
31922
+ }),
31923
+ content: readContent(runtimeEntry)
31924
+ };
31925
+ });
31644
31926
  complete("completed", {
31645
31927
  conversationId: inferredConversationId,
31646
31928
  entriesReturned: out.length
@@ -31657,11 +31939,11 @@ function registerFalsePositiveTools(server, opts) {
31657
31939
  });
31658
31940
  server.registerTool("sage_report_false_positive", {
31659
31941
  title: `${branding.name}: Report False Positive`,
31660
- description: `Report ${branding.name} audit log entries as false positives to the backend, scoped to the current conversation.
31942
+ description: `Report ${branding.name} runtime or Skill Analyzer audit entries as false positives to the backend. Runtime entries are scoped to the current conversation; skill entries require explicit entry_ids.
31661
31943
 
31662
31944
  USAGE \u2014 IMPORTANT:
31663
- 1. ALWAYS call \`sage_list_audit_entries\` FIRST to inspect the audit log and locate the specific deny/ask verdict the user considers a false positive.
31664
- 2. ALWAYS pass the \`entry_ids\` parameter with just the relevant deny/ask entry id(s). Up to ${MAX_EXPLICIT_ENTRIES} entry_ids may be reported per call.
31945
+ 1. ALWAYS call \`sage_list_audit_entries\` FIRST to inspect the audit log and locate the specific runtime or skill verdict the user considers a false positive.
31946
+ 2. ALWAYS pass the \`entry_ids\` parameter with just the relevant entry id(s); it is required for skill verdicts. Up to ${MAX_EXPLICIT_ENTRIES} entry_ids may be reported per call.
31665
31947
  3. Only when entry_id discovery is impossible should \`entry_ids\` be omitted; in that fallback at most ${MAX_IMPLICIT_ENTRIES} most recent deny/ask entries for the conversation are submitted, which may include irrelevant verdicts and floods the backend with noise.`,
31666
31948
  inputSchema: ReportInputSchema
31667
31949
  }, async ({ conversation_id, description, reasoning, agent_runtime_version, entry_ids, dry_run }) => {
@@ -31685,31 +31967,35 @@ USAGE \u2014 IMPORTANT:
31685
31967
  const config2 = await loadConfig(void 0, logger2);
31686
31968
  const endpoint = resolveEndpoint("/v2/fp-report");
31687
31969
  const timeoutSeconds = Number(process.env.SAGE_FALSE_POSITIVE_TIMEOUT_SECONDS) || 5;
31688
- const scanLimit = 2e3;
31689
- const entries = await getRecentEntries(config2.logging, scanLimit);
31970
+ const entries = await getRecentEntries(config2.logging, MAX_AUDIT_SCAN_ENTRIES);
31690
31971
  const runtime = entries.filter(isRuntimeVerdictEntry);
31972
+ const cachedSkills = await cachedSkillVerdictEntries(config2, logger2);
31973
+ const skills = latestReportableSkillVerdicts([...entries, ...cachedSkills], skillCacheTtlMs(config2));
31691
31974
  const inferredConversationId = conversation_id ?? getConversationId(runtime.slice().reverse().find((e) => !!getConversationId(e)) ?? {});
31692
- if (!inferredConversationId) {
31693
- complete("skipped", { skippedReason: "no_runtime_entries", reportsCount: 0 });
31694
- return textResult("No runtime_verdict entries found in the audit log.", true);
31695
- }
31696
- let filtered = runtime.filter((e) => getConversationId(e) === inferredConversationId);
31975
+ let filtered;
31697
31976
  if (entry_ids && entry_ids.length > 0) {
31698
- const wanted = new Set(entry_ids);
31699
- filtered = filtered.filter((e) => {
31700
- const id = getEntryId(e);
31701
- return id ? wanted.has(id) : false;
31702
- });
31703
- if (filtered.length > MAX_EXPLICIT_ENTRIES) {
31977
+ if (entry_ids.length > MAX_EXPLICIT_ENTRIES) {
31704
31978
  complete("failed_validation", {
31705
31979
  conversationId: inferredConversationId,
31706
- reportsCount: filtered.length,
31980
+ entryIdsCount: entry_ids.length,
31707
31981
  maxEntries: MAX_EXPLICIT_ENTRIES
31708
31982
  });
31709
- return textResult(`Too many entry_ids (${filtered.length}). Provide at most ${MAX_EXPLICIT_ENTRIES} specific entry_ids per report call.`, true);
31983
+ return textResult(`Too many entry_ids (${entry_ids.length}). Provide at most ${MAX_EXPLICIT_ENTRIES} specific entry_ids per report call.`, true);
31710
31984
  }
31985
+ const wanted = new Set(entry_ids);
31986
+ const scopedRuntime = inferredConversationId ? runtime.filter((e) => getConversationId(e) === inferredConversationId) : [];
31987
+ filtered = [...scopedRuntime, ...skills].filter((e) => {
31988
+ const id = getEntryId(e);
31989
+ return id !== void 0 && wanted.has(id);
31990
+ });
31711
31991
  } else {
31712
- filtered = filtered.filter((e) => {
31992
+ if (!inferredConversationId) {
31993
+ complete("skipped", { skippedReason: "no_runtime_entries", reportsCount: 0 });
31994
+ return textResult("No runtime_verdict entries found. Skill verdicts must be reported with explicit entry_ids.", true);
31995
+ }
31996
+ filtered = runtime.filter((e) => {
31997
+ if (getConversationId(e) !== inferredConversationId)
31998
+ return false;
31713
31999
  const v = asString(e.verdict);
31714
32000
  return v === "deny" || v === "ask";
31715
32001
  });
@@ -31724,7 +32010,7 @@ USAGE \u2014 IMPORTANT:
31724
32010
  skippedReason: "no_matching_entry_ids",
31725
32011
  reportsCount: 0
31726
32012
  });
31727
- return textResult("No matching audit entries found for the provided entry_ids in this conversation. Run sage_list_audit_entries first.", true);
32013
+ return textResult("No matching reportable audit entries found for the provided entry_ids. Run sage_list_audit_entries first.", true);
31728
32014
  }
31729
32015
  complete("skipped", {
31730
32016
  conversationId: inferredConversationId,
@@ -31750,19 +32036,22 @@ USAGE \u2014 IMPORTANT:
31750
32036
  ${reasoning}`;
31751
32037
  const extendedInfo = await loadExtendedInfo(void 0, logger2).catch(() => null);
31752
32038
  const reports = filtered.map((e) => {
32039
+ const isSkill = isReportableSkillVerdictEntry(e);
32040
+ const runtimeEntry = isSkill ? void 0 : e;
31753
32041
  const entry_id = getEntryId(e);
31754
32042
  const timestamp = asString(e.timestamp) ?? (/* @__PURE__ */ new Date()).toISOString();
31755
32043
  const agent_runtime = asString(e.agent_runtime) ?? "unknown";
31756
- const tool_type = asString(e.tool_name) ?? "Unknown";
31757
- const verdict = asString(e.verdict) ?? "deny";
31758
- const wasAllowed = verdict === "allow" || e.user_override === true;
32044
+ const analyzerVerdict = isSkill ? asString(e.verdict)?.toUpperCase() : void 0;
32045
+ const tool_type = isSkill ? "Skill" : asString(runtimeEntry?.tool_name) ?? "Unknown";
32046
+ const verdict = isSkill ? analyzerVerdict === "CRITICAL" ? "deny" : "ask" : asString(e.verdict) ?? "deny";
32047
+ const wasAllowed = !isSkill && (verdict === "allow" || runtimeEntry?.user_override === true);
31759
32048
  const user_action = wasAllowed ? "allowed" : "blocked";
31760
- const hook_type = asHookType(e.hook_type) ?? "PreToolUse";
31761
- const signals = parseAuditSignals(e.signals, {
32049
+ const hook_type = isSkill ? "SessionStart" : asHookType(runtimeEntry?.hook_type) ?? "PreToolUse";
32050
+ const signals = parseRuntimeAuditSignals(e.signals, {
31762
32051
  includePiContentSnippet: !dry_run
31763
32052
  });
31764
- const content = readContent(e);
31765
- const contentSnippet = readContentSnippet(e);
32053
+ const content = runtimeEntry ? readContent(runtimeEntry) : {};
32054
+ const contentSnippet = runtimeEntry ? readContentSnippet(runtimeEntry) : void 0;
31766
32055
  const bestEffortSignals = {};
31767
32056
  if (signals.heuristics)
31768
32057
  bestEffortSignals.heuristics = signals.heuristics;
@@ -31776,6 +32065,11 @@ ${reasoning}`;
31776
32065
  bestEffortSignals.pi_checks = signals.pi_checks;
31777
32066
  if (signals.amsi_checks)
31778
32067
  bestEffortSignals.amsi_checks = signals.amsi_checks;
32068
+ if (isSkill) {
32069
+ const signal = analyzerVerdict ? buildSkillCheckSignal(e.skill_id, analyzerVerdict, asString(e.job_id)) : void 0;
32070
+ if (signal)
32071
+ bestEffortSignals.skill_checks = [signal];
32072
+ }
31779
32073
  const baseReportPayload = {
31780
32074
  ...buildSageProxyEnvelope({
31781
32075
  iid,
@@ -31791,11 +32085,11 @@ ${reasoning}`;
31791
32085
  user_action,
31792
32086
  timestamp,
31793
32087
  ...Object.keys(bestEffortSignals).length > 0 ? { signals: bestEffortSignals } : {},
31794
- content,
32088
+ ...Object.keys(content).length > 0 ? { content } : {},
31795
32089
  ...contentSnippet && !dry_run ? { content_snippet: contentSnippet } : {}
31796
32090
  },
31797
32091
  comment,
31798
- event_id: entry_id ?? (0, import_node_crypto2.randomUUID)()
32092
+ event_id: entry_id ?? (0, import_node_crypto4.randomUUID)()
31799
32093
  };
31800
32094
  const reportPayload = mergeExtendedInfo(baseReportPayload, extendedInfo);
31801
32095
  return { entry_id, payload: reportPayload };
@@ -31849,7 +32143,8 @@ ${JSON.stringify(summary, null, 2)}`, true);
31849
32143
  conversationId: inferredConversationId,
31850
32144
  reported: filtered.length
31851
32145
  });
31852
- return textResult(`Reported ${filtered.length} audit entr${filtered.length === 1 ? "y" : "ies"} for conversation_id=${inferredConversationId}.`);
32146
+ const scope = inferredConversationId ? ` for conversation_id=${inferredConversationId}` : "";
32147
+ return textResult(`Reported ${filtered.length} audit entr${filtered.length === 1 ? "y" : "ies"}${scope}.`);
31853
32148
  } catch (e) {
31854
32149
  logger2.warn("MCP tool failed", {
31855
32150
  toolName: "sage_report_false_positive",
@@ -31897,7 +32192,7 @@ async function main() {
31897
32192
  );
31898
32193
  const branding = resolveBranding(config2.brand_key, logger);
31899
32194
  await runSageMcpServerStdio({
31900
- version: "0.13.0",
32195
+ version: "0.14.0",
31901
32196
  logger,
31902
32197
  branding
31903
32198
  });