@gendigital/sage-openclaw 0.4.3 → 0.5.2

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
@@ -3978,10 +3978,10 @@ var require_resolve_block_map = __commonJS({
3978
3978
  let offset = bm.offset;
3979
3979
  let commentEnd = null;
3980
3980
  for (const collItem of bm.items) {
3981
- const { start, key, sep, value } = collItem;
3981
+ const { start, key, sep: sep2, value } = collItem;
3982
3982
  const keyProps = resolveProps.resolveProps(start, {
3983
3983
  indicator: "explicit-key-ind",
3984
- next: key ?? sep?.[0],
3984
+ next: key ?? sep2?.[0],
3985
3985
  offset,
3986
3986
  onError,
3987
3987
  parentIndent: bm.indent,
@@ -3995,7 +3995,7 @@ var require_resolve_block_map = __commonJS({
3995
3995
  else if ("indent" in key && key.indent !== bm.indent)
3996
3996
  onError(offset, "BAD_INDENT", startColMsg);
3997
3997
  }
3998
- if (!keyProps.anchor && !keyProps.tag && !sep) {
3998
+ if (!keyProps.anchor && !keyProps.tag && !sep2) {
3999
3999
  commentEnd = keyProps.end;
4000
4000
  if (keyProps.comment) {
4001
4001
  if (map.comment)
@@ -4019,7 +4019,7 @@ var require_resolve_block_map = __commonJS({
4019
4019
  ctx.atKey = false;
4020
4020
  if (utilMapIncludes.mapIncludes(ctx, map.items, keyNode))
4021
4021
  onError(keyStart, "DUPLICATE_KEY", "Map keys must be unique");
4022
- const valueProps = resolveProps.resolveProps(sep ?? [], {
4022
+ const valueProps = resolveProps.resolveProps(sep2 ?? [], {
4023
4023
  indicator: "map-value-ind",
4024
4024
  next: value,
4025
4025
  offset: keyNode.range[2],
@@ -4035,7 +4035,7 @@ var require_resolve_block_map = __commonJS({
4035
4035
  if (ctx.options.strict && keyProps.start < valueProps.found.offset - 1024)
4036
4036
  onError(keyNode.range, "KEY_OVER_1024_CHARS", "The : indicator must be at most 1024 chars after the start of an implicit block mapping key");
4037
4037
  }
4038
- const valueNode = value ? composeNode(ctx, value, valueProps, onError) : composeEmptyNode(ctx, offset, sep, null, valueProps, onError);
4038
+ const valueNode = value ? composeNode(ctx, value, valueProps, onError) : composeEmptyNode(ctx, offset, sep2, null, valueProps, onError);
4039
4039
  if (ctx.schema.compat)
4040
4040
  utilFlowIndentCheck.flowIndentCheck(bm.indent, value, onError);
4041
4041
  offset = valueNode.range[2];
@@ -4126,7 +4126,7 @@ var require_resolve_end = __commonJS({
4126
4126
  let comment = "";
4127
4127
  if (end) {
4128
4128
  let hasSpace = false;
4129
- let sep = "";
4129
+ let sep2 = "";
4130
4130
  for (const token of end) {
4131
4131
  const { source, type } = token;
4132
4132
  switch (type) {
@@ -4140,13 +4140,13 @@ var require_resolve_end = __commonJS({
4140
4140
  if (!comment)
4141
4141
  comment = cb;
4142
4142
  else
4143
- comment += sep + cb;
4144
- sep = "";
4143
+ comment += sep2 + cb;
4144
+ sep2 = "";
4145
4145
  break;
4146
4146
  }
4147
4147
  case "newline":
4148
4148
  if (comment)
4149
- sep += source;
4149
+ sep2 += source;
4150
4150
  hasSpace = true;
4151
4151
  break;
4152
4152
  default:
@@ -4189,18 +4189,18 @@ var require_resolve_flow_collection = __commonJS({
4189
4189
  let offset = fc.offset + fc.start.source.length;
4190
4190
  for (let i = 0; i < fc.items.length; ++i) {
4191
4191
  const collItem = fc.items[i];
4192
- const { start, key, sep, value } = collItem;
4192
+ const { start, key, sep: sep2, value } = collItem;
4193
4193
  const props = resolveProps.resolveProps(start, {
4194
4194
  flow: fcName,
4195
4195
  indicator: "explicit-key-ind",
4196
- next: key ?? sep?.[0],
4196
+ next: key ?? sep2?.[0],
4197
4197
  offset,
4198
4198
  onError,
4199
4199
  parentIndent: fc.indent,
4200
4200
  startOnNewline: false
4201
4201
  });
4202
4202
  if (!props.found) {
4203
- if (!props.anchor && !props.tag && !sep && !value) {
4203
+ if (!props.anchor && !props.tag && !sep2 && !value) {
4204
4204
  if (i === 0 && props.comma)
4205
4205
  onError(props.comma, "UNEXPECTED_TOKEN", `Unexpected , in ${fcName}`);
4206
4206
  else if (i < fc.items.length - 1)
@@ -4254,8 +4254,8 @@ var require_resolve_flow_collection = __commonJS({
4254
4254
  }
4255
4255
  }
4256
4256
  }
4257
- if (!isMap && !sep && !props.found) {
4258
- const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end, sep, null, props, onError);
4257
+ if (!isMap && !sep2 && !props.found) {
4258
+ const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end, sep2, null, props, onError);
4259
4259
  coll.items.push(valueNode);
4260
4260
  offset = valueNode.range[2];
4261
4261
  if (isBlock(value))
@@ -4267,7 +4267,7 @@ var require_resolve_flow_collection = __commonJS({
4267
4267
  if (isBlock(key))
4268
4268
  onError(keyNode.range, "BLOCK_IN_FLOW", blockMsg);
4269
4269
  ctx.atKey = false;
4270
- const valueProps = resolveProps.resolveProps(sep ?? [], {
4270
+ const valueProps = resolveProps.resolveProps(sep2 ?? [], {
4271
4271
  flow: fcName,
4272
4272
  indicator: "map-value-ind",
4273
4273
  next: value,
@@ -4278,8 +4278,8 @@ var require_resolve_flow_collection = __commonJS({
4278
4278
  });
4279
4279
  if (valueProps.found) {
4280
4280
  if (!isMap && !props.found && ctx.options.strict) {
4281
- if (sep)
4282
- for (const st of sep) {
4281
+ if (sep2)
4282
+ for (const st of sep2) {
4283
4283
  if (st === valueProps.found)
4284
4284
  break;
4285
4285
  if (st.type === "newline") {
@@ -4296,7 +4296,7 @@ var require_resolve_flow_collection = __commonJS({
4296
4296
  else
4297
4297
  onError(valueProps.start, "MISSING_CHAR", `Missing , or : between ${fcName} items`);
4298
4298
  }
4299
- const valueNode = value ? composeNode(ctx, value, valueProps, onError) : valueProps.found ? composeEmptyNode(ctx, valueProps.end, sep, null, valueProps, onError) : null;
4299
+ const valueNode = value ? composeNode(ctx, value, valueProps, onError) : valueProps.found ? composeEmptyNode(ctx, valueProps.end, sep2, null, valueProps, onError) : null;
4300
4300
  if (valueNode) {
4301
4301
  if (isBlock(value))
4302
4302
  onError(valueNode.range, "BLOCK_IN_FLOW", blockMsg);
@@ -4476,7 +4476,7 @@ var require_resolve_block_scalar = __commonJS({
4476
4476
  chompStart = i + 1;
4477
4477
  }
4478
4478
  let value = "";
4479
- let sep = "";
4479
+ let sep2 = "";
4480
4480
  let prevMoreIndented = false;
4481
4481
  for (let i = 0; i < contentStart; ++i)
4482
4482
  value += lines[i][0].slice(trimIndent) + "\n";
@@ -4493,24 +4493,24 @@ var require_resolve_block_scalar = __commonJS({
4493
4493
  indent = "";
4494
4494
  }
4495
4495
  if (type === Scalar.Scalar.BLOCK_LITERAL) {
4496
- value += sep + indent.slice(trimIndent) + content;
4497
- sep = "\n";
4496
+ value += sep2 + indent.slice(trimIndent) + content;
4497
+ sep2 = "\n";
4498
4498
  } else if (indent.length > trimIndent || content[0] === " ") {
4499
- if (sep === " ")
4500
- sep = "\n";
4501
- else if (!prevMoreIndented && sep === "\n")
4502
- sep = "\n\n";
4503
- value += sep + indent.slice(trimIndent) + content;
4504
- sep = "\n";
4499
+ if (sep2 === " ")
4500
+ sep2 = "\n";
4501
+ else if (!prevMoreIndented && sep2 === "\n")
4502
+ sep2 = "\n\n";
4503
+ value += sep2 + indent.slice(trimIndent) + content;
4504
+ sep2 = "\n";
4505
4505
  prevMoreIndented = true;
4506
4506
  } else if (content === "") {
4507
- if (sep === "\n")
4507
+ if (sep2 === "\n")
4508
4508
  value += "\n";
4509
4509
  else
4510
- sep = "\n";
4510
+ sep2 = "\n";
4511
4511
  } else {
4512
- value += sep + content;
4513
- sep = " ";
4512
+ value += sep2 + content;
4513
+ sep2 = " ";
4514
4514
  prevMoreIndented = false;
4515
4515
  }
4516
4516
  }
@@ -4692,25 +4692,25 @@ var require_resolve_flow_scalar = __commonJS({
4692
4692
  if (!match)
4693
4693
  return source;
4694
4694
  let res = match[1];
4695
- let sep = " ";
4695
+ let sep2 = " ";
4696
4696
  let pos = first.lastIndex;
4697
4697
  line.lastIndex = pos;
4698
4698
  while (match = line.exec(source)) {
4699
4699
  if (match[1] === "") {
4700
- if (sep === "\n")
4701
- res += sep;
4700
+ if (sep2 === "\n")
4701
+ res += sep2;
4702
4702
  else
4703
- sep = "\n";
4703
+ sep2 = "\n";
4704
4704
  } else {
4705
- res += sep + match[1];
4706
- sep = " ";
4705
+ res += sep2 + match[1];
4706
+ sep2 = " ";
4707
4707
  }
4708
4708
  pos = line.lastIndex;
4709
4709
  }
4710
4710
  const last = /[ \t]*(.*)/sy;
4711
4711
  last.lastIndex = pos;
4712
4712
  match = last.exec(source);
4713
- return res + sep + (match?.[1] ?? "");
4713
+ return res + sep2 + (match?.[1] ?? "");
4714
4714
  }
4715
4715
  function doubleQuotedValue(source, onError) {
4716
4716
  let res = "";
@@ -5511,14 +5511,14 @@ var require_cst_stringify = __commonJS({
5511
5511
  }
5512
5512
  }
5513
5513
  }
5514
- function stringifyItem({ start, key, sep, value }) {
5514
+ function stringifyItem({ start, key, sep: sep2, value }) {
5515
5515
  let res = "";
5516
5516
  for (const st of start)
5517
5517
  res += st.source;
5518
5518
  if (key)
5519
5519
  res += stringifyToken(key);
5520
- if (sep)
5521
- for (const st of sep)
5520
+ if (sep2)
5521
+ for (const st of sep2)
5522
5522
  res += st.source;
5523
5523
  if (value)
5524
5524
  res += stringifyToken(value);
@@ -6667,18 +6667,18 @@ var require_parser = __commonJS({
6667
6667
  if (this.type === "map-value-ind") {
6668
6668
  const prev = getPrevProps(this.peek(2));
6669
6669
  const start = getFirstKeyStartProps(prev);
6670
- let sep;
6670
+ let sep2;
6671
6671
  if (scalar.end) {
6672
- sep = scalar.end;
6673
- sep.push(this.sourceToken);
6672
+ sep2 = scalar.end;
6673
+ sep2.push(this.sourceToken);
6674
6674
  delete scalar.end;
6675
6675
  } else
6676
- sep = [this.sourceToken];
6676
+ sep2 = [this.sourceToken];
6677
6677
  const map = {
6678
6678
  type: "block-map",
6679
6679
  offset: scalar.offset,
6680
6680
  indent: scalar.indent,
6681
- items: [{ start, key: scalar, sep }]
6681
+ items: [{ start, key: scalar, sep: sep2 }]
6682
6682
  };
6683
6683
  this.onKeyLine = true;
6684
6684
  this.stack[this.stack.length - 1] = map;
@@ -6831,15 +6831,15 @@ var require_parser = __commonJS({
6831
6831
  } else if (isFlowToken(it.key) && !includesToken(it.sep, "newline")) {
6832
6832
  const start2 = getFirstKeyStartProps(it.start);
6833
6833
  const key = it.key;
6834
- const sep = it.sep;
6835
- sep.push(this.sourceToken);
6834
+ const sep2 = it.sep;
6835
+ sep2.push(this.sourceToken);
6836
6836
  delete it.key;
6837
6837
  delete it.sep;
6838
6838
  this.stack.push({
6839
6839
  type: "block-map",
6840
6840
  offset: this.offset,
6841
6841
  indent: this.indent,
6842
- items: [{ start: start2, key, sep }]
6842
+ items: [{ start: start2, key, sep: sep2 }]
6843
6843
  });
6844
6844
  } else if (start.length > 0) {
6845
6845
  it.sep = it.sep.concat(start, this.sourceToken);
@@ -7033,13 +7033,13 @@ var require_parser = __commonJS({
7033
7033
  const prev = getPrevProps(parent);
7034
7034
  const start = getFirstKeyStartProps(prev);
7035
7035
  fixFlowSeqItems(fc);
7036
- const sep = fc.end.splice(1, fc.end.length);
7037
- sep.push(this.sourceToken);
7036
+ const sep2 = fc.end.splice(1, fc.end.length);
7037
+ sep2.push(this.sourceToken);
7038
7038
  const map = {
7039
7039
  type: "block-map",
7040
7040
  offset: fc.offset,
7041
7041
  indent: fc.indent,
7042
- items: [{ start, key: fc, sep }]
7042
+ items: [{ start, key: fc, sep: sep2 }]
7043
7043
  };
7044
7044
  this.onKeyLine = true;
7045
7045
  this.stack[this.stack.length - 1] = map;
@@ -7324,11 +7324,6 @@ __export(index_exports, {
7324
7324
  });
7325
7325
  module.exports = __toCommonJS(index_exports);
7326
7326
 
7327
- // src/approval-store.ts
7328
- var import_node_crypto5 = require("node:crypto");
7329
- var import_node_os5 = require("node:os");
7330
- var import_node_path9 = require("node:path");
7331
-
7332
7327
  // ../core/dist/config.js
7333
7328
  var import_node_os = require("node:os");
7334
7329
  var import_node_path2 = require("node:path");
@@ -7348,9 +7343,36 @@ function getFileContentRaw(path) {
7348
7343
  async function atomicWriteJson(path, data) {
7349
7344
  await fsPromises.mkdir((0, import_node_path.dirname)(path), { recursive: true });
7350
7345
  const tmp = `${path}.${(0, import_node_crypto.randomBytes)(6).toString("hex")}.tmp`;
7351
- await fsPromises.writeFile(tmp, `${JSON.stringify(data, null, 2)}
7346
+ try {
7347
+ await fsPromises.writeFile(tmp, `${JSON.stringify(data, null, 2)}
7352
7348
  `, { mode: 384 });
7353
- await fsPromises.rename(tmp, path);
7349
+ await fsPromises.rename(tmp, path);
7350
+ } catch (err) {
7351
+ try {
7352
+ await fsPromises.unlink(tmp);
7353
+ } catch {
7354
+ }
7355
+ throw err;
7356
+ }
7357
+ }
7358
+ async function pruneOrphanedTmpFiles(resolvedDir, maxAgeMs = 3e5) {
7359
+ try {
7360
+ const entries = await fsPromises.readdir(resolvedDir);
7361
+ const now = Date.now();
7362
+ for (const entry of entries) {
7363
+ if (!entry.endsWith(".tmp"))
7364
+ continue;
7365
+ try {
7366
+ const fullPath = (0, import_node_path.join)(resolvedDir, entry);
7367
+ const s = await fsPromises.stat(fullPath);
7368
+ if (now - s.mtimeMs > maxAgeMs) {
7369
+ await fsPromises.unlink(fullPath);
7370
+ }
7371
+ } catch {
7372
+ }
7373
+ }
7374
+ } catch {
7375
+ }
7354
7376
  }
7355
7377
 
7356
7378
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
@@ -11445,7 +11467,9 @@ var AllowlistConfigSchema = external_exports.object({
11445
11467
  var LoggingConfigSchema = external_exports.object({
11446
11468
  enabled: external_exports.boolean().default(true),
11447
11469
  log_clean: external_exports.boolean().default(false),
11448
- path: external_exports.string().default("~/.sage/audit.jsonl")
11470
+ path: external_exports.string().default("~/.sage/audit.jsonl"),
11471
+ max_bytes: external_exports.number().int().min(0).default(5 * 1024 * 1024),
11472
+ max_files: external_exports.number().int().min(0).default(3)
11449
11473
  });
11450
11474
  var FileCheckConfigSchema = external_exports.object({
11451
11475
  endpoint: external_exports.string().optional(),
@@ -11458,10 +11482,14 @@ var PackageCheckConfigSchema = external_exports.object({
11458
11482
  // v1: all scoped packages (@scope/pkg) are skipped automatically.
11459
11483
  // Future: add private_scopes / public_scopes config for fine-grained control.
11460
11484
  });
11485
+ var AmsiCheckConfigSchema = external_exports.object({
11486
+ enabled: external_exports.boolean().default(true)
11487
+ });
11461
11488
  var ConfigSchema = external_exports.object({
11462
11489
  url_check: UrlCheckConfigSchema.default({}),
11463
11490
  file_check: FileCheckConfigSchema.default({}),
11464
11491
  package_check: PackageCheckConfigSchema.default({}),
11492
+ amsi_check: AmsiCheckConfigSchema.default({}),
11465
11493
  heuristics_enabled: external_exports.boolean().default(true),
11466
11494
  cache: CacheConfigSchema.default({}),
11467
11495
  allowlist: AllowlistConfigSchema.default({}),
@@ -11471,37 +11499,112 @@ var ConfigSchema = external_exports.object({
11471
11499
  });
11472
11500
 
11473
11501
  // ../core/dist/config.js
11474
- var DEFAULT_CONFIG_PATH = (0, import_node_path2.join)((0, import_node_os.homedir)(), ".sage", "config.json");
11502
+ var SAGE_DIR = "~/.sage";
11503
+ function currentHomeDir() {
11504
+ const envHome = process.env.HOME?.trim();
11505
+ return envHome && envHome.length > 0 ? envHome : (0, import_node_os.homedir)();
11506
+ }
11507
+ function resolvedSageDir() {
11508
+ return resolvePath(SAGE_DIR);
11509
+ }
11510
+ function defaultConfigPath() {
11511
+ return (0, import_node_path2.join)(resolvedSageDir(), "config.json");
11512
+ }
11513
+ function defaultCachePath() {
11514
+ return (0, import_node_path2.join)(resolvedSageDir(), "cache.json");
11515
+ }
11516
+ function defaultAllowlistPath() {
11517
+ return (0, import_node_path2.join)(resolvedSageDir(), "allowlist.json");
11518
+ }
11519
+ function defaultAuditPath() {
11520
+ return (0, import_node_path2.join)(resolvedSageDir(), "audit.jsonl");
11521
+ }
11475
11522
  function resolvePath(pathStr) {
11476
11523
  if (pathStr.startsWith("~/") || pathStr === "~") {
11477
- return (0, import_node_path2.join)((0, import_node_os.homedir)(), pathStr.slice(1));
11524
+ return (0, import_node_path2.join)(currentHomeDir(), pathStr.slice(1));
11478
11525
  }
11479
11526
  return pathStr;
11480
11527
  }
11528
+ function isWithinDirectory(baseDir, targetPath) {
11529
+ const rel = (0, import_node_path2.relative)(baseDir, targetPath);
11530
+ if (rel === "")
11531
+ return true;
11532
+ if ((0, import_node_path2.isAbsolute)(rel))
11533
+ return false;
11534
+ return rel !== ".." && !rel.startsWith(`..${import_node_path2.sep}`);
11535
+ }
11536
+ function normalizeStateFilePath(configuredPath, fallbackPath, field, logger) {
11537
+ const sageDir = resolvedSageDir();
11538
+ const trimmed = configuredPath.trim();
11539
+ if (trimmed === "") {
11540
+ logger.warn(`Config ${field}.path is empty; using default`, {
11541
+ configuredPath,
11542
+ defaultPath: fallbackPath
11543
+ });
11544
+ return fallbackPath;
11545
+ }
11546
+ const expanded = resolvePath(trimmed);
11547
+ const resolved = (0, import_node_path2.isAbsolute)(expanded) ? (0, import_node_path2.resolve)(expanded) : (0, import_node_path2.resolve)(sageDir, expanded);
11548
+ if (isWithinDirectory(sageDir, resolved)) {
11549
+ if (resolved === sageDir) {
11550
+ logger.warn(`Config ${field}.path must point to a file; using default`, {
11551
+ configuredPath,
11552
+ defaultPath: fallbackPath
11553
+ });
11554
+ return fallbackPath;
11555
+ }
11556
+ return resolved;
11557
+ }
11558
+ logger.warn(`Config ${field}.path escapes ${sageDir}; using default`, {
11559
+ configuredPath,
11560
+ defaultPath: fallbackPath
11561
+ });
11562
+ return fallbackPath;
11563
+ }
11564
+ function sanitizeConfigPaths(config, logger) {
11565
+ const cachePath = defaultCachePath();
11566
+ const allowlistPath = defaultAllowlistPath();
11567
+ const auditPath = defaultAuditPath();
11568
+ return {
11569
+ ...config,
11570
+ cache: {
11571
+ ...config.cache,
11572
+ path: normalizeStateFilePath(config.cache.path, cachePath, "cache", logger)
11573
+ },
11574
+ allowlist: {
11575
+ ...config.allowlist,
11576
+ path: normalizeStateFilePath(config.allowlist.path, allowlistPath, "allowlist", logger)
11577
+ },
11578
+ logging: {
11579
+ ...config.logging,
11580
+ path: normalizeStateFilePath(config.logging.path, auditPath, "logging", logger)
11581
+ }
11582
+ };
11583
+ }
11481
11584
  async function loadConfig(configPath, logger = nullLogger) {
11482
- const path = configPath ?? DEFAULT_CONFIG_PATH;
11585
+ const path = configPath ?? defaultConfigPath();
11483
11586
  let raw;
11484
11587
  try {
11485
11588
  raw = await getFileContent(path);
11486
11589
  } catch {
11487
- return ConfigSchema.parse({});
11590
+ return sanitizeConfigPaths(ConfigSchema.parse({}), logger);
11488
11591
  }
11489
11592
  let data;
11490
11593
  try {
11491
11594
  data = JSON.parse(raw);
11492
11595
  } catch (e) {
11493
11596
  logger.warn(`Failed to parse config from ${path}`, { error: String(e) });
11494
- return ConfigSchema.parse({});
11597
+ return sanitizeConfigPaths(ConfigSchema.parse({}), logger);
11495
11598
  }
11496
11599
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
11497
11600
  logger.warn(`Config file ${path} does not contain a JSON object`);
11498
- return ConfigSchema.parse({});
11601
+ return sanitizeConfigPaths(ConfigSchema.parse({}), logger);
11499
11602
  }
11500
11603
  try {
11501
- return ConfigSchema.parse(data);
11604
+ return sanitizeConfigPaths(ConfigSchema.parse(data), logger);
11502
11605
  } catch (e) {
11503
11606
  logger.warn(`Config validation failed, using defaults`, { error: String(e) });
11504
- return ConfigSchema.parse({});
11607
+ return sanitizeConfigPaths(ConfigSchema.parse({}), logger);
11505
11608
  }
11506
11609
  }
11507
11610
 
@@ -11531,6 +11634,16 @@ function normalizeFilePath(raw) {
11531
11634
  function emptyAllowlist() {
11532
11635
  return { urls: {}, commands: {}, filePaths: {} };
11533
11636
  }
11637
+ function addEntry(record, key, reason, originalVerdict) {
11638
+ record[key] = { addedAt: (/* @__PURE__ */ new Date()).toISOString(), reason, originalVerdict };
11639
+ }
11640
+ function removeEntry(record, key) {
11641
+ if (key in record) {
11642
+ delete record[key];
11643
+ return true;
11644
+ }
11645
+ return false;
11646
+ }
11534
11647
  function parseEntries(raw) {
11535
11648
  const entries = {};
11536
11649
  for (const [key, entryData] of Object.entries(raw)) {
@@ -11583,6 +11696,29 @@ async function loadAllowlist(config, logger = nullLogger) {
11583
11696
  filePaths
11584
11697
  };
11585
11698
  }
11699
+ async function saveAllowlist(allowlist, config, logger = nullLogger) {
11700
+ const path = resolvePath(config.path);
11701
+ const serializeEntries = (entries) => Object.fromEntries(Object.entries(entries).map(([key, entry]) => [
11702
+ key,
11703
+ {
11704
+ added_at: entry.addedAt,
11705
+ reason: entry.reason,
11706
+ original_verdict: entry.originalVerdict
11707
+ }
11708
+ ]));
11709
+ const data = {
11710
+ urls: serializeEntries(allowlist.urls),
11711
+ commands: serializeEntries(allowlist.commands),
11712
+ file_paths: serializeEntries(allowlist.filePaths)
11713
+ };
11714
+ try {
11715
+ await atomicWriteJson(path, data);
11716
+ return true;
11717
+ } catch (e) {
11718
+ logger.warn(`Failed to save allowlist to ${path}`, { error: String(e) });
11719
+ return false;
11720
+ }
11721
+ }
11586
11722
  function isAllowlisted(allowlist, artifacts) {
11587
11723
  for (const artifact of artifacts) {
11588
11724
  if (artifact.type !== "command") {
@@ -11606,6 +11742,115 @@ function isAllowlisted(allowlist, artifacts) {
11606
11742
  }
11607
11743
  return false;
11608
11744
  }
11745
+ function addUrl(allowlist, url, reason, originalVerdict) {
11746
+ addEntry(allowlist.urls, normalizeUrl(url), reason, originalVerdict);
11747
+ }
11748
+ function addCommand(allowlist, command, reason, originalVerdict) {
11749
+ addEntry(allowlist.commands, hashCommand(command), reason, originalVerdict);
11750
+ }
11751
+ function addFilePath(allowlist, filePath, reason, originalVerdict) {
11752
+ addEntry(allowlist.filePaths, normalizeFilePath(filePath), reason, originalVerdict);
11753
+ }
11754
+ function removeFilePath(allowlist, filePath) {
11755
+ return removeEntry(allowlist.filePaths, normalizeFilePath(filePath));
11756
+ }
11757
+ function removeUrl(allowlist, url) {
11758
+ return removeEntry(allowlist.urls, normalizeUrl(url));
11759
+ }
11760
+ function removeCommand(allowlist, commandHash) {
11761
+ return removeEntry(allowlist.commands, commandHash);
11762
+ }
11763
+
11764
+ // ../core/dist/approval-store.js
11765
+ var import_node_crypto3 = require("node:crypto");
11766
+ var PENDING_STALE_MS = 60 * 60 * 1e3;
11767
+ var APPROVED_TTL_MS = 10 * 60 * 1e3;
11768
+ var ApprovalStore = class _ApprovalStore {
11769
+ pending = /* @__PURE__ */ new Map();
11770
+ approved = /* @__PURE__ */ new Map();
11771
+ static actionId(toolName, params, sessionId) {
11772
+ const payload = JSON.stringify({ toolName, params, sessionId });
11773
+ return (0, import_node_crypto3.createHash)("sha256").update(payload).digest("hex");
11774
+ }
11775
+ static artifactId(type, value) {
11776
+ return `${type}:${value}`;
11777
+ }
11778
+ setPending(actionId, entry) {
11779
+ this.pending.set(actionId, { ...entry, artifacts: [...entry.artifacts] });
11780
+ }
11781
+ deletePending(actionId) {
11782
+ this.pending.delete(actionId);
11783
+ }
11784
+ approve(actionId) {
11785
+ const entry = this.pending.get(actionId);
11786
+ if (!entry)
11787
+ return null;
11788
+ const now = Date.now();
11789
+ this.approved.set(actionId, {
11790
+ artifacts: [...entry.artifacts],
11791
+ approvedAt: now,
11792
+ expiresAt: now + APPROVED_TTL_MS
11793
+ });
11794
+ this.pending.delete(actionId);
11795
+ return entry;
11796
+ }
11797
+ isApproved(actionId) {
11798
+ const entry = this.approved.get(actionId);
11799
+ if (!entry)
11800
+ return false;
11801
+ if (Date.now() >= entry.expiresAt) {
11802
+ this.approved.delete(actionId);
11803
+ return false;
11804
+ }
11805
+ return true;
11806
+ }
11807
+ hasApprovedArtifact(type, value) {
11808
+ return this.findApprovedAction(type, value) !== null;
11809
+ }
11810
+ consumeApprovedArtifact(type, value) {
11811
+ const id = _ApprovalStore.artifactId(type, value);
11812
+ for (const [actionId, entry] of this.approved.entries()) {
11813
+ if (Date.now() >= entry.expiresAt) {
11814
+ this.approved.delete(actionId);
11815
+ continue;
11816
+ }
11817
+ const idx = entry.artifacts.findIndex((a) => _ApprovalStore.artifactId(a.type, a.value) === id);
11818
+ if (idx !== -1) {
11819
+ entry.artifacts.splice(idx, 1);
11820
+ if (entry.artifacts.length === 0) {
11821
+ this.approved.delete(actionId);
11822
+ }
11823
+ return true;
11824
+ }
11825
+ }
11826
+ return false;
11827
+ }
11828
+ findApprovedAction(type, value) {
11829
+ const id = _ApprovalStore.artifactId(type, value);
11830
+ for (const [actionId, entry] of this.approved.entries()) {
11831
+ if (Date.now() >= entry.expiresAt) {
11832
+ this.approved.delete(actionId);
11833
+ continue;
11834
+ }
11835
+ if (entry.artifacts.some((artifact) => _ApprovalStore.artifactId(artifact.type, artifact.value) === id)) {
11836
+ return actionId;
11837
+ }
11838
+ }
11839
+ return null;
11840
+ }
11841
+ cleanup(now = Date.now()) {
11842
+ for (const [actionId, entry] of this.pending.entries()) {
11843
+ if (now - entry.createdAt >= PENDING_STALE_MS) {
11844
+ this.pending.delete(actionId);
11845
+ }
11846
+ }
11847
+ for (const [actionId, entry] of this.approved.entries()) {
11848
+ if (now >= entry.expiresAt) {
11849
+ this.approved.delete(actionId);
11850
+ }
11851
+ }
11852
+ }
11853
+ };
11609
11854
 
11610
11855
  // ../core/dist/audit-log.js
11611
11856
  var import_promises = require("node:fs/promises");
@@ -11618,11 +11863,45 @@ function toolInputSummary(toolName, toolInput) {
11618
11863
  if (toolName === "WebFetch") {
11619
11864
  return String(toolInput.url ?? "").slice(0, MAX_SUMMARY_LEN);
11620
11865
  }
11621
- if (toolName === "Write" || toolName === "Edit") {
11866
+ if (toolName === "Write" || toolName === "Edit" || toolName === "Read" || toolName === "Delete") {
11622
11867
  return String(toolInput.file_path ?? "").slice(0, MAX_SUMMARY_LEN);
11623
11868
  }
11624
11869
  return JSON.stringify(toolInput).slice(0, MAX_SUMMARY_LEN);
11625
11870
  }
11871
+ async function rotateIfNeeded(filePath, maxBytes, maxFiles) {
11872
+ if (maxBytes <= 0 || maxFiles <= 0)
11873
+ return;
11874
+ let size;
11875
+ try {
11876
+ const s = await (0, import_promises.stat)(filePath);
11877
+ size = s.size;
11878
+ } catch {
11879
+ return;
11880
+ }
11881
+ if (size < maxBytes)
11882
+ return;
11883
+ try {
11884
+ await (0, import_promises.unlink)(`${filePath}.${maxFiles}`);
11885
+ } catch {
11886
+ }
11887
+ for (let i = maxFiles - 1; i >= 1; i--) {
11888
+ try {
11889
+ await (0, import_promises.rename)(`${filePath}.${i}`, `${filePath}.${i + 1}`);
11890
+ } catch {
11891
+ }
11892
+ }
11893
+ try {
11894
+ await (0, import_promises.rename)(filePath, `${filePath}.1`);
11895
+ } catch {
11896
+ }
11897
+ }
11898
+ async function appendEntry(config, entry) {
11899
+ const path = resolvePath(config.path);
11900
+ await (0, import_promises.mkdir)((0, import_node_path4.dirname)(path), { recursive: true });
11901
+ await rotateIfNeeded(path, config.max_bytes, config.max_files);
11902
+ await (0, import_promises.appendFile)(path, `${JSON.stringify(entry)}
11903
+ `);
11904
+ }
11626
11905
  async function logVerdict(config, sessionId, toolName, toolInput, verdict, userOverride = false) {
11627
11906
  if (!config.enabled)
11628
11907
  return;
@@ -11641,11 +11920,8 @@ async function logVerdict(config, sessionId, toolName, toolInput, verdict, userO
11641
11920
  source: verdict.source,
11642
11921
  user_override: userOverride
11643
11922
  };
11644
- const path = resolvePath(config.path);
11645
11923
  try {
11646
- await (0, import_promises.mkdir)((0, import_node_path4.dirname)(path), { recursive: true });
11647
- await (0, import_promises.appendFile)(path, `${JSON.stringify(entry)}
11648
- `);
11924
+ await appendEntry(config, entry);
11649
11925
  } catch {
11650
11926
  }
11651
11927
  }
@@ -11660,11 +11936,8 @@ async function logPluginScan(config, pluginKey, pluginVersion, findings) {
11660
11936
  findings_count: findings.length,
11661
11937
  findings
11662
11938
  };
11663
- const path = resolvePath(config.path);
11664
11939
  try {
11665
- await (0, import_promises.mkdir)((0, import_node_path4.dirname)(path), { recursive: true });
11666
- await (0, import_promises.appendFile)(path, `${JSON.stringify(entry)}
11667
- `);
11940
+ await appendEntry(config, entry);
11668
11941
  } catch {
11669
11942
  }
11670
11943
  }
@@ -11807,169 +12080,802 @@ var VerdictCache = class {
11807
12080
  }
11808
12081
  };
11809
12082
 
11810
- // ../core/dist/version.js
11811
- var VERSION = true ? "0.4.3" : "dev";
11812
-
11813
- // ../core/dist/clients/file-check.js
11814
- var DEFAULT_TIMEOUT = 5;
11815
- var SERVICE_NAME = "sage";
11816
- var MAX_HASHES_PER_REQUEST = 50;
11817
- function getProviderTld() {
11818
- return "com";
12083
+ // ../core/dist/clients/amsi.js
12084
+ var import_node_child_process = require("node:child_process");
12085
+ var AMSI_RESULT_DETECTED = 32768;
12086
+ var AMSI_RESULT_BLOCKED_BY_ADMIN_START = 16384;
12087
+ var MAX_SCAN_LENGTH = 1048576;
12088
+ var PS_TIMEOUT = 15e3;
12089
+ function isWSL() {
12090
+ if (process.platform !== "linux")
12091
+ return false;
12092
+ return !!process.env.WSL_DISTRO_NAME;
11819
12093
  }
11820
- var REQUEST_HEADERS = [
11821
- { name: "Accept", value: "application/json" },
11822
- { name: "Content-Type", value: "application/json" },
11823
- { name: "User-Agent", value: SERVICE_NAME }
11824
- ];
11825
- function getProviderName() {
11826
- return "avast";
12094
+ function isAmsiSupported() {
12095
+ return process.platform === "win32" || isWSL();
11827
12096
  }
11828
- var FileCheckClient = class {
11829
- endpoint;
11830
- timeoutMs;
12097
+ function interpretAmsiResult(amsiResult, content, contentName) {
12098
+ return {
12099
+ content: content.length > 200 ? `${content.slice(0, 200)}...` : content,
12100
+ contentName,
12101
+ amsiResult,
12102
+ isDetected: amsiResult >= AMSI_RESULT_DETECTED,
12103
+ isBlockedByAdmin: amsiResult >= AMSI_RESULT_BLOCKED_BY_ADMIN_START && amsiResult < AMSI_RESULT_DETECTED
12104
+ };
12105
+ }
12106
+ var KoffiAmsiBackend = class {
11831
12107
  logger;
11832
- constructor(config, logger = nullLogger) {
11833
- this.endpoint = config?.endpoint ?? this.resolveEndpoint();
11834
- this.timeoutMs = (config?.timeout_seconds ?? DEFAULT_TIMEOUT) * 1e3;
12108
+ context = null;
12109
+ session = null;
12110
+ available = false;
12111
+ /* biome-ignore lint/suspicious/noExplicitAny: koffi FFI functions have dynamic signatures */
12112
+ fnScanBuffer = null;
12113
+ /* biome-ignore lint/suspicious/noExplicitAny: koffi FFI functions have dynamic signatures */
12114
+ fnCloseSession = null;
12115
+ /* biome-ignore lint/suspicious/noExplicitAny: koffi FFI functions have dynamic signatures */
12116
+ fnUninitialize = null;
12117
+ constructor(logger) {
11835
12118
  this.logger = logger;
11836
12119
  }
11837
- buildPath() {
11838
- return "/file-check";
12120
+ get isAvailable() {
12121
+ return this.available;
11839
12122
  }
11840
- async checkHash(hash) {
12123
+ async init() {
11841
12124
  try {
11842
- const response = await fetch(this.endpoint, {
11843
- method: "POST",
11844
- headers: Object.fromEntries(REQUEST_HEADERS.map((h) => [h.name, h.value])),
11845
- body: JSON.stringify({
11846
- requests: [{ key: { sha256: hash } }],
11847
- client_info: {
11848
- product: {
11849
- version_app: VERSION
11850
- }
12125
+ const koffiModule = await import("koffi");
12126
+ const koffi = koffiModule.default ?? koffiModule;
12127
+ const lib = koffi.load("amsi.dll");
12128
+ const _HAMSICONTEXT = koffi.pointer("HAMSICONTEXT", koffi.opaque());
12129
+ const _HAMSISESSION = koffi.pointer("HAMSISESSION", koffi.opaque());
12130
+ const AmsiInitialize = lib.func("int32 __stdcall AmsiInitialize(str16 appName, _Out_ HAMSICONTEXT *ctx)");
12131
+ const AmsiOpenSession = lib.func("int32 __stdcall AmsiOpenSession(HAMSICONTEXT ctx, _Out_ HAMSISESSION *session)");
12132
+ this.fnScanBuffer = lib.func("int32 __stdcall AmsiScanBuffer(HAMSICONTEXT ctx, void *buf, uint32 len, str16 contentName, HAMSISESSION session, _Out_ int32 *result)");
12133
+ this.fnCloseSession = lib.func("void __stdcall AmsiCloseSession(HAMSICONTEXT ctx, HAMSISESSION session)");
12134
+ this.fnUninitialize = lib.func("void __stdcall AmsiUninitialize(HAMSICONTEXT ctx)");
12135
+ const ctxOut = [null];
12136
+ const hr = AmsiInitialize("Sage", ctxOut);
12137
+ if (hr !== 0) {
12138
+ this.logger.warn("AMSI: koffi AmsiInitialize failed", { hr });
12139
+ return;
12140
+ }
12141
+ this.context = ctxOut[0];
12142
+ const sessOut = [null];
12143
+ const hr2 = AmsiOpenSession(this.context, sessOut);
12144
+ if (hr2 !== 0) {
12145
+ this.logger.warn("AMSI: koffi AmsiOpenSession failed", { hr: hr2 });
12146
+ try {
12147
+ if (this.fnUninitialize) {
12148
+ this.fnUninitialize(this.context);
11851
12149
  }
11852
- }),
11853
- signal: AbortSignal.timeout(this.timeoutMs)
11854
- });
11855
- if (!response.ok) {
11856
- this.logger.warn(`FileCheck HTTP error: ${response.status}`);
11857
- return null;
12150
+ } catch {
12151
+ }
12152
+ this.context = null;
12153
+ return;
11858
12154
  }
11859
- const data = await response.json();
11860
- return this.parseResponse(data);
12155
+ this.session = sessOut[0];
12156
+ this.available = true;
12157
+ this.logger.debug("AMSI: koffi backend initialized");
11861
12158
  } catch (e) {
11862
- this.logger.warn("FileCheck request failed", { error: String(e) });
11863
- return null;
12159
+ this.logger.debug("AMSI: koffi backend init failed", { error: String(e) });
12160
+ this.available = false;
11864
12161
  }
11865
12162
  }
11866
- getSubdomain() {
11867
- return "svc";
11868
- }
11869
- resolveEndpoint() {
11870
- return `https://${SERVICE_NAME}-proxy.${this.buildDomain()}${this.buildPath()}`;
11871
- }
11872
- parseResponse(data) {
12163
+ async scanString(content, contentName) {
12164
+ if (!this.available || !this.fnScanBuffer || !this.context || !this.session) {
12165
+ return null;
12166
+ }
11873
12167
  try {
11874
- const responses = data.responses ?? [];
11875
- if (responses.length === 0)
12168
+ const truncated = content.length > MAX_SCAN_LENGTH ? content.slice(0, MAX_SCAN_LENGTH) : content;
12169
+ const buf = Buffer.from(truncated, "utf-8");
12170
+ const resultOut = [0];
12171
+ const hr = this.fnScanBuffer(this.context, buf, buf.length, contentName, this.session, resultOut);
12172
+ if (hr !== 0) {
12173
+ this.logger.warn("AMSI: koffi AmsiScanBuffer failed", { hr, contentName });
11876
12174
  return null;
11877
- const first = responses[0];
11878
- const severity = first.severity ?? "SEVERITY_UNKNOWN";
11879
- const malwareNames = first.malware_name ?? [];
11880
- const detectionNames = malwareNames.filter((n) => typeof n === "string" && n.length > 0);
11881
- return { severity, detectionNames };
12175
+ }
12176
+ const amsiResult = resultOut[0] ?? 0;
12177
+ this.logger.debug("AMSI: koffi scan result", { contentName, amsiResult });
12178
+ return interpretAmsiResult(amsiResult, content, contentName);
11882
12179
  } catch (e) {
11883
- this.logger.warn("Failed to parse FileCheck response", { error: String(e) });
12180
+ this.logger.warn("AMSI: koffi scanBuffer failed", { error: String(e), contentName });
11884
12181
  return null;
11885
12182
  }
11886
12183
  }
11887
- async checkHashes(hashes) {
11888
- if (hashes.length === 0)
11889
- return [];
11890
- const batches = [];
11891
- for (let i = 0; i < hashes.length; i += MAX_HASHES_PER_REQUEST) {
11892
- batches.push(hashes.slice(i, i + MAX_HASHES_PER_REQUEST));
11893
- }
11894
- const batchResults = await Promise.all(batches.map((batch) => this.checkHashBatch(batch)));
11895
- return batchResults.flat();
11896
- }
11897
- async checkHashBatch(hashes) {
12184
+ close() {
11898
12185
  try {
11899
- const response = await fetch(this.endpoint, {
11900
- method: "POST",
11901
- headers: Object.fromEntries(REQUEST_HEADERS.map((h) => [h.name, h.value])),
11902
- body: JSON.stringify({
11903
- requests: hashes.map((h) => ({ key: { sha256: h } })),
11904
- client_info: {
11905
- product_name: SERVICE_NAME,
11906
- product_version: VERSION
11907
- }
11908
- }),
11909
- signal: AbortSignal.timeout(this.timeoutMs)
11910
- });
11911
- if (!response.ok) {
11912
- this.logger.warn(`FileCheck batch HTTP error: ${response.status}`);
11913
- return [];
12186
+ if (this.session && this.context && this.fnCloseSession) {
12187
+ this.fnCloseSession(this.context, this.session);
11914
12188
  }
11915
- const data = await response.json();
11916
- return this.parseBatchResponse(data);
11917
- } catch (e) {
11918
- this.logger.warn("FileCheck batch request failed", { error: String(e) });
11919
- return [];
12189
+ } catch {
11920
12190
  }
11921
- }
11922
- parseBatchResponse(data) {
11923
- const results = [];
11924
12191
  try {
11925
- const responses = data.responses ?? [];
11926
- for (const entry of responses) {
11927
- const key = entry.key;
11928
- const sha256 = key?.sha256 ?? "";
11929
- if (!sha256)
11930
- continue;
11931
- const severity = entry.severity ?? "SEVERITY_UNKNOWN";
11932
- const malwareNames = entry.malware_name ?? [];
11933
- const detectionNames = malwareNames.filter((n) => typeof n === "string" && n.length > 0);
11934
- results.push({ sha256, severity, detectionNames });
12192
+ if (this.context && this.fnUninitialize) {
12193
+ this.fnUninitialize(this.context);
11935
12194
  }
11936
- } catch (e) {
11937
- this.logger.warn("Failed to parse FileCheck batch response", { error: String(e) });
12195
+ } catch {
11938
12196
  }
11939
- return results;
11940
- }
11941
- buildDomain() {
11942
- return [this.getSubdomain(), getProviderName(), getProviderTld()].join(".");
12197
+ this.session = null;
12198
+ this.context = null;
12199
+ this.available = false;
11943
12200
  }
11944
12201
  };
12202
+ var AMSI_CSHARP_TYPE = `
12203
+ using System;
12204
+ using System.Runtime.InteropServices;
12205
+ using System.Text;
11945
12206
 
11946
- // ../core/dist/clients/url-check.js
11947
- var DEFAULT_TIMEOUT2 = 5;
11948
- var MAX_URLS_PER_REQUEST = 50;
11949
- var SERVICE_NAME2 = "sage";
11950
- function getProviderTld2() {
11951
- return "com";
11952
- }
11953
- var REQUEST_HEADERS2 = [
11954
- { name: "Accept", value: "application/json" },
11955
- { name: "Content-Type", value: "application/json" },
11956
- { name: "User-Agent", value: SERVICE_NAME2 }
11957
- ];
12207
+ public class SageAmsi {
12208
+ [DllImport("amsi.dll", CharSet = CharSet.Unicode)]
12209
+ static extern int AmsiInitialize(string appName, out IntPtr ctx);
12210
+
12211
+ [DllImport("amsi.dll")]
12212
+ static extern int AmsiOpenSession(IntPtr ctx, out IntPtr session);
12213
+
12214
+ [DllImport("amsi.dll", CharSet = CharSet.Unicode)]
12215
+ static extern int AmsiScanBuffer(
12216
+ IntPtr ctx, byte[] buf, uint len,
12217
+ string contentName, IntPtr session, out int result);
12218
+
12219
+ [DllImport("amsi.dll")]
12220
+ static extern void AmsiCloseSession(IntPtr ctx, IntPtr session);
12221
+
12222
+ [DllImport("amsi.dll")]
12223
+ static extern void AmsiUninitialize(IntPtr ctx);
12224
+
12225
+ private static IntPtr _ctx;
12226
+ private static IntPtr _session;
12227
+ private static bool _initialized;
12228
+
12229
+ public static bool Init() {
12230
+ int hr = AmsiInitialize("Sage", out _ctx);
12231
+ if (hr != 0) return false;
12232
+ hr = AmsiOpenSession(_ctx, out _session);
12233
+ if (hr != 0) {
12234
+ AmsiUninitialize(_ctx);
12235
+ return false;
12236
+ }
12237
+ _initialized = true;
12238
+ return true;
12239
+ }
12240
+
12241
+ public static int Scan(string content, string contentName) {
12242
+ if (!_initialized) return -1;
12243
+ byte[] bytes = Encoding.UTF8.GetBytes(content);
12244
+ int result;
12245
+ int hr = AmsiScanBuffer(_ctx, bytes, (uint)bytes.Length,
12246
+ contentName, _session, out result);
12247
+ if (hr != 0) return -1;
12248
+ return result;
12249
+ }
12250
+
12251
+ public static void Shutdown() {
12252
+ if (!_initialized) return;
12253
+ AmsiCloseSession(_ctx, _session);
12254
+ AmsiUninitialize(_ctx);
12255
+ _initialized = false;
12256
+ }
12257
+ }
12258
+ `;
12259
+ var PS_PERSISTENT_SCRIPT = `
12260
+ $ErrorActionPreference = 'Stop'
12261
+ try {
12262
+ Add-Type -TypeDefinition @'
12263
+ ${AMSI_CSHARP_TYPE}
12264
+ '@
12265
+
12266
+ if (-not [SageAmsi]::Init()) {
12267
+ [Console]::Error.Write("AMSI initialization failed")
12268
+ exit 1
12269
+ }
12270
+
12271
+ [Console]::Out.WriteLine("READY")
12272
+ [Console]::Out.Flush()
12273
+
12274
+ while ($null -ne ($line = [Console]::In.ReadLine())) {
12275
+ try {
12276
+ $req = $line | ConvertFrom-Json
12277
+ $content = $req.content
12278
+ $cname = $req.contentName
12279
+ if (-not $cname) { $cname = 'sage:test' }
12280
+ $result = [SageAmsi]::Scan($content, $cname)
12281
+ [Console]::Out.WriteLine($result)
12282
+ [Console]::Out.Flush()
12283
+ } catch {
12284
+ [Console]::Out.WriteLine("-1")
12285
+ [Console]::Out.Flush()
12286
+ }
12287
+ }
12288
+
12289
+ [SageAmsi]::Shutdown()
12290
+ } catch {
12291
+ [Console]::Error.Write($_.Exception.Message)
12292
+ exit 1
12293
+ }
12294
+ `;
12295
+ var PS_ONESHOT_SCRIPT = `
12296
+ $ErrorActionPreference = 'Stop'
12297
+ try {
12298
+ Add-Type -TypeDefinition @'
12299
+ ${AMSI_CSHARP_TYPE}
12300
+ '@
12301
+
12302
+ if (-not [SageAmsi]::Init()) {
12303
+ [Console]::Out.WriteLine("-1")
12304
+ exit
12305
+ }
12306
+
12307
+ $line = [Console]::In.ReadLine()
12308
+ $req = $line | ConvertFrom-Json
12309
+ $content = $req.content
12310
+ $cname = $req.contentName
12311
+ if (-not $cname) { $cname = 'sage:scan' }
12312
+ $result = [SageAmsi]::Scan($content, $cname)
12313
+ [SageAmsi]::Shutdown()
12314
+ [Console]::Out.WriteLine($result)
12315
+ [Console]::Out.Flush()
12316
+ } catch {
12317
+ [Console]::Out.WriteLine("-1")
12318
+ }
12319
+ `;
12320
+ var PersistentPowershellAmsiBackend = class {
12321
+ logger;
12322
+ process = null;
12323
+ available = false;
12324
+ stdoutBuffer = "";
12325
+ pendingResponse = null;
12326
+ constructor(logger) {
12327
+ this.logger = logger;
12328
+ }
12329
+ get isAvailable() {
12330
+ return this.available;
12331
+ }
12332
+ waitForLine(timeout) {
12333
+ return new Promise((resolve3, reject) => {
12334
+ const timer = setTimeout(() => {
12335
+ this.pendingResponse = null;
12336
+ reject(new Error("timeout"));
12337
+ }, timeout);
12338
+ this.pendingResponse = { resolve: resolve3, reject, timer };
12339
+ });
12340
+ }
12341
+ async init() {
12342
+ try {
12343
+ this.process = (0, import_node_child_process.spawn)("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PS_PERSISTENT_SCRIPT], {
12344
+ stdio: ["pipe", "pipe", "pipe"],
12345
+ windowsHide: true
12346
+ });
12347
+ this.process.on("error", (err) => {
12348
+ this.logger.debug("AMSI: PowerShell process error", { error: String(err) });
12349
+ this.available = false;
12350
+ if (this.pendingResponse) {
12351
+ const { reject, timer } = this.pendingResponse;
12352
+ this.pendingResponse = null;
12353
+ clearTimeout(timer);
12354
+ reject(err);
12355
+ }
12356
+ });
12357
+ this.process.on("exit", () => {
12358
+ this.available = false;
12359
+ if (this.pendingResponse) {
12360
+ const { reject, timer } = this.pendingResponse;
12361
+ this.pendingResponse = null;
12362
+ clearTimeout(timer);
12363
+ reject(new Error("process exited"));
12364
+ }
12365
+ });
12366
+ this.process.stdout?.on("data", (chunk) => {
12367
+ this.stdoutBuffer += chunk.toString();
12368
+ let idx = this.stdoutBuffer.indexOf("\n");
12369
+ while (idx !== -1) {
12370
+ const line = this.stdoutBuffer.slice(0, idx).trim();
12371
+ this.stdoutBuffer = this.stdoutBuffer.slice(idx + 1);
12372
+ if (this.pendingResponse) {
12373
+ const { resolve: resolve3, timer } = this.pendingResponse;
12374
+ this.pendingResponse = null;
12375
+ clearTimeout(timer);
12376
+ resolve3(line);
12377
+ }
12378
+ idx = this.stdoutBuffer.indexOf("\n");
12379
+ }
12380
+ });
12381
+ this.process.stdin?.on("error", () => {
12382
+ });
12383
+ this.process.stderr?.on("data", (chunk) => {
12384
+ this.logger.debug("AMSI: PowerShell stderr", {
12385
+ data: chunk.toString().slice(0, 200)
12386
+ });
12387
+ });
12388
+ const readyLine = await this.waitForLine(PS_TIMEOUT);
12389
+ if (readyLine === "READY") {
12390
+ this.available = true;
12391
+ this.logger.debug("AMSI: PowerShell persistent backend initialized");
12392
+ } else {
12393
+ this.logger.debug("AMSI: PowerShell unexpected ready signal", { readyLine });
12394
+ this.close();
12395
+ }
12396
+ } catch (e) {
12397
+ this.logger.debug("AMSI: PowerShell persistent backend init failed", {
12398
+ error: String(e)
12399
+ });
12400
+ this.close();
12401
+ }
12402
+ }
12403
+ async scanString(content, contentName) {
12404
+ if (!this.available || !this.process)
12405
+ return null;
12406
+ const truncated = content.length > MAX_SCAN_LENGTH ? content.slice(0, MAX_SCAN_LENGTH) : content;
12407
+ try {
12408
+ const req = JSON.stringify({ content: truncated, contentName });
12409
+ this.process.stdin?.write(`${req}
12410
+ `);
12411
+ const line = await this.waitForLine(PS_TIMEOUT);
12412
+ const amsiResult = parseInt(line, 10);
12413
+ if (Number.isNaN(amsiResult) || amsiResult < 0) {
12414
+ this.logger.warn("AMSI: PowerShell scan returned invalid result", {
12415
+ stdout: line.slice(0, 100),
12416
+ contentName
12417
+ });
12418
+ return null;
12419
+ }
12420
+ this.logger.debug("AMSI: PowerShell scan result", { contentName, amsiResult });
12421
+ return interpretAmsiResult(amsiResult, content, contentName);
12422
+ } catch (e) {
12423
+ this.logger.warn("AMSI: PowerShell scan failed", { error: String(e), contentName });
12424
+ return null;
12425
+ }
12426
+ }
12427
+ close() {
12428
+ if (this.process) {
12429
+ try {
12430
+ this.process.stdin?.end();
12431
+ } catch {
12432
+ }
12433
+ try {
12434
+ this.process.kill();
12435
+ } catch {
12436
+ }
12437
+ this.process = null;
12438
+ }
12439
+ if (this.pendingResponse) {
12440
+ const { reject, timer } = this.pendingResponse;
12441
+ this.pendingResponse = null;
12442
+ clearTimeout(timer);
12443
+ reject(new Error("closed"));
12444
+ }
12445
+ this.available = false;
12446
+ }
12447
+ };
12448
+ var WslPowershellAmsiBackend = class {
12449
+ logger;
12450
+ available = false;
12451
+ constructor(logger) {
12452
+ this.logger = logger;
12453
+ }
12454
+ get isAvailable() {
12455
+ return this.available;
12456
+ }
12457
+ async init() {
12458
+ this.available = true;
12459
+ this.logger.debug("AMSI: PowerShell one-shot backend ready (WSL)");
12460
+ }
12461
+ async scanString(content, contentName) {
12462
+ if (!this.available)
12463
+ return null;
12464
+ const truncated = content.length > MAX_SCAN_LENGTH ? content.slice(0, MAX_SCAN_LENGTH) : content;
12465
+ return new Promise((resolve3) => {
12466
+ try {
12467
+ const ps = (0, import_node_child_process.spawn)("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PS_ONESHOT_SCRIPT], { stdio: ["pipe", "pipe", "pipe"] });
12468
+ let stdout = "";
12469
+ const timer = setTimeout(() => {
12470
+ this.logger.warn("AMSI: PowerShell one-shot timeout", { contentName });
12471
+ try {
12472
+ ps.kill();
12473
+ } catch {
12474
+ }
12475
+ resolve3(null);
12476
+ }, PS_TIMEOUT);
12477
+ ps.stdout?.on("data", (chunk) => {
12478
+ stdout += chunk.toString();
12479
+ });
12480
+ ps.stderr?.on("data", (chunk) => {
12481
+ this.logger.debug("AMSI: PowerShell one-shot stderr", {
12482
+ data: chunk.toString().slice(0, 200)
12483
+ });
12484
+ });
12485
+ ps.on("error", (err) => {
12486
+ clearTimeout(timer);
12487
+ this.logger.debug("AMSI: PowerShell one-shot error", { error: String(err) });
12488
+ resolve3(null);
12489
+ });
12490
+ ps.on("exit", () => {
12491
+ clearTimeout(timer);
12492
+ const amsiResult = parseInt(stdout.trim(), 10);
12493
+ if (Number.isNaN(amsiResult) || amsiResult < 0) {
12494
+ this.logger.warn("AMSI: PowerShell one-shot invalid result", {
12495
+ stdout: stdout.slice(0, 100),
12496
+ contentName
12497
+ });
12498
+ resolve3(null);
12499
+ return;
12500
+ }
12501
+ this.logger.debug("AMSI: PowerShell one-shot result", { contentName, amsiResult });
12502
+ resolve3(interpretAmsiResult(amsiResult, content, contentName));
12503
+ });
12504
+ const req = JSON.stringify({ content: truncated, contentName });
12505
+ ps.stdin?.write(`${req}
12506
+ `);
12507
+ ps.stdin?.end();
12508
+ } catch (e) {
12509
+ this.logger.warn("AMSI: PowerShell one-shot failed", { error: String(e), contentName });
12510
+ resolve3(null);
12511
+ }
12512
+ });
12513
+ }
12514
+ close() {
12515
+ this.available = false;
12516
+ }
12517
+ };
12518
+ var AmsiClient = class {
12519
+ logger;
12520
+ backend = null;
12521
+ constructor(logger = nullLogger) {
12522
+ this.logger = logger;
12523
+ }
12524
+ get isAvailable() {
12525
+ return this.backend?.isAvailable ?? false;
12526
+ }
12527
+ async init() {
12528
+ const wsl = isWSL();
12529
+ if (process.platform !== "win32" && !wsl) {
12530
+ this.logger.debug("AMSI: skipping, not Windows");
12531
+ return;
12532
+ }
12533
+ if (!wsl) {
12534
+ const koffi = new KoffiAmsiBackend(this.logger);
12535
+ await koffi.init();
12536
+ if (koffi.isAvailable) {
12537
+ this.backend = koffi;
12538
+ return;
12539
+ }
12540
+ }
12541
+ const ps = wsl ? new WslPowershellAmsiBackend(this.logger) : new PersistentPowershellAmsiBackend(this.logger);
12542
+ await ps.init();
12543
+ if (ps.isAvailable) {
12544
+ this.backend = ps;
12545
+ return;
12546
+ }
12547
+ this.logger.debug("AMSI: no backend available");
12548
+ }
12549
+ async scanString(content, contentName) {
12550
+ return await this.backend?.scanString(content, contentName) ?? null;
12551
+ }
12552
+ close() {
12553
+ this.backend?.close();
12554
+ this.backend = null;
12555
+ }
12556
+ };
12557
+
12558
+ // ../core/dist/version.js
12559
+ var VERSION = true ? "0.5.2" : "dev";
12560
+
12561
+ // ../core/dist/clients/file-check.js
12562
+ var DEFAULT_TIMEOUT = 5;
12563
+ var SERVICE_NAME = "sage";
12564
+ var MAX_HASHES_PER_REQUEST = 50;
12565
+ function getProviderTld() {
12566
+ return "com";
12567
+ }
12568
+ var REQUEST_HEADERS = [
12569
+ { name: "Accept", value: "application/json" },
12570
+ { name: "Content-Type", value: "application/json" },
12571
+ { name: "User-Agent", value: SERVICE_NAME }
12572
+ ];
12573
+ function getProviderName() {
12574
+ return "avast";
12575
+ }
12576
+ var FileCheckClient = class {
12577
+ endpoint;
12578
+ timeoutMs;
12579
+ logger;
12580
+ constructor(config, logger = nullLogger) {
12581
+ this.endpoint = config?.endpoint ?? this.resolveEndpoint();
12582
+ this.timeoutMs = (config?.timeout_seconds ?? DEFAULT_TIMEOUT) * 1e3;
12583
+ this.logger = logger;
12584
+ }
12585
+ buildPath() {
12586
+ return "/file-check";
12587
+ }
12588
+ async checkHash(hash) {
12589
+ try {
12590
+ const response = await fetch(this.endpoint, {
12591
+ method: "POST",
12592
+ headers: Object.fromEntries(REQUEST_HEADERS.map((h) => [h.name, h.value])),
12593
+ body: JSON.stringify({
12594
+ requests: [{ key: { sha256: hash } }],
12595
+ client_info: {
12596
+ product: {
12597
+ version_app: VERSION
12598
+ }
12599
+ }
12600
+ }),
12601
+ signal: AbortSignal.timeout(this.timeoutMs)
12602
+ });
12603
+ if (!response.ok) {
12604
+ this.logger.warn(`FileCheck HTTP error: ${response.status}`);
12605
+ return null;
12606
+ }
12607
+ const data = await response.json();
12608
+ return this.parseResponse(data);
12609
+ } catch (e) {
12610
+ this.logger.warn("FileCheck request failed", { error: String(e) });
12611
+ return null;
12612
+ }
12613
+ }
12614
+ getSubdomain() {
12615
+ return "svc";
12616
+ }
12617
+ resolveEndpoint() {
12618
+ return `https://${SERVICE_NAME}-proxy.${this.buildDomain()}${this.buildPath()}`;
12619
+ }
12620
+ parseResponse(data) {
12621
+ try {
12622
+ const responses = data.responses ?? [];
12623
+ if (responses.length === 0)
12624
+ return null;
12625
+ const first = responses[0];
12626
+ const severity = first.severity ?? "SEVERITY_UNKNOWN";
12627
+ const malwareNames = first.malware_name ?? [];
12628
+ const detectionNames = malwareNames.filter((n) => typeof n === "string" && n.length > 0);
12629
+ return { severity, detectionNames };
12630
+ } catch (e) {
12631
+ this.logger.warn("Failed to parse FileCheck response", { error: String(e) });
12632
+ return null;
12633
+ }
12634
+ }
12635
+ async checkHashes(hashes) {
12636
+ if (hashes.length === 0)
12637
+ return [];
12638
+ const batches = [];
12639
+ for (let i = 0; i < hashes.length; i += MAX_HASHES_PER_REQUEST) {
12640
+ batches.push(hashes.slice(i, i + MAX_HASHES_PER_REQUEST));
12641
+ }
12642
+ const batchResults = await Promise.all(batches.map((batch) => this.checkHashBatch(batch)));
12643
+ return batchResults.flat();
12644
+ }
12645
+ async checkHashBatch(hashes) {
12646
+ try {
12647
+ const response = await fetch(this.endpoint, {
12648
+ method: "POST",
12649
+ headers: Object.fromEntries(REQUEST_HEADERS.map((h) => [h.name, h.value])),
12650
+ body: JSON.stringify({
12651
+ requests: hashes.map((h) => ({ key: { sha256: h } })),
12652
+ client_info: {
12653
+ product_name: SERVICE_NAME,
12654
+ product_version: VERSION
12655
+ }
12656
+ }),
12657
+ signal: AbortSignal.timeout(this.timeoutMs)
12658
+ });
12659
+ if (!response.ok) {
12660
+ this.logger.warn(`FileCheck batch HTTP error: ${response.status}`);
12661
+ return [];
12662
+ }
12663
+ const data = await response.json();
12664
+ return this.parseBatchResponse(data);
12665
+ } catch (e) {
12666
+ this.logger.warn("FileCheck batch request failed", { error: String(e) });
12667
+ return [];
12668
+ }
12669
+ }
12670
+ parseBatchResponse(data) {
12671
+ const results = [];
12672
+ try {
12673
+ const responses = data.responses ?? [];
12674
+ for (const entry of responses) {
12675
+ const key = entry.key;
12676
+ const sha256 = key?.sha256 ?? "";
12677
+ if (!sha256)
12678
+ continue;
12679
+ const severity = entry.severity ?? "SEVERITY_UNKNOWN";
12680
+ const malwareNames = entry.malware_name ?? [];
12681
+ const detectionNames = malwareNames.filter((n) => typeof n === "string" && n.length > 0);
12682
+ results.push({ sha256, severity, detectionNames });
12683
+ }
12684
+ } catch (e) {
12685
+ this.logger.warn("Failed to parse FileCheck batch response", { error: String(e) });
12686
+ }
12687
+ return results;
12688
+ }
12689
+ buildDomain() {
12690
+ return [this.getSubdomain(), getProviderName(), getProviderTld()].join(".");
12691
+ }
12692
+ };
12693
+
12694
+ // ../core/dist/clients/package-registry.js
12695
+ var NPM_REGISTRY = "https://registry.npmjs.org";
12696
+ var PYPI_REGISTRY = "https://pypi.org/pypi";
12697
+ var DEFAULT_TIMEOUT2 = 5;
12698
+ var NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
12699
+ var PYPI_NAME_RE = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/;
12700
+ var RegistryClient = class {
12701
+ timeoutMs;
12702
+ logger;
12703
+ constructor(config, logger = nullLogger) {
12704
+ this.timeoutMs = (config?.timeout_seconds ?? DEFAULT_TIMEOUT2) * 1e3;
12705
+ this.logger = logger;
12706
+ }
12707
+ async getPackageMetadata(name, registry, version) {
12708
+ if (registry === "npm")
12709
+ return this.getNpmMetadata(name, version);
12710
+ return this.getPypiMetadata(name, version);
12711
+ }
12712
+ async getNpmMetadata(name, version) {
12713
+ if (!NPM_NAME_RE.test(name)) {
12714
+ this.logger.warn("Invalid npm package name rejected", { name });
12715
+ return null;
12716
+ }
12717
+ const encodedName = name.includes("/") ? `${name.split("/")[0]}%2f${name.split("/")[1]}` : name;
12718
+ const url = `${NPM_REGISTRY}/${encodedName}`;
12719
+ try {
12720
+ const response = await fetch(url, {
12721
+ signal: AbortSignal.timeout(this.timeoutMs)
12722
+ });
12723
+ if (response.status === 404)
12724
+ return null;
12725
+ if (!response.ok) {
12726
+ this.logger.warn(`npm registry HTTP error: ${response.status}`, { name });
12727
+ throw new Error(`npm registry HTTP ${response.status}`);
12728
+ }
12729
+ const data = await response.json();
12730
+ return this.parseNpmResponse(name, data, version);
12731
+ } catch (e) {
12732
+ this.logger.warn("npm registry request failed", { name, error: String(e) });
12733
+ throw e;
12734
+ }
12735
+ }
12736
+ parseNpmResponse(name, data, requestedVersion) {
12737
+ try {
12738
+ const distTags = data["dist-tags"] ?? {};
12739
+ const latestTag = distTags.latest;
12740
+ if (!latestTag)
12741
+ return null;
12742
+ const versions = data.versions ?? {};
12743
+ const effectiveVersion = requestedVersion && distTags[requestedVersion] ? distTags[requestedVersion] : requestedVersion;
12744
+ const requestedVersionFound = !effectiveVersion || Boolean(versions[effectiveVersion]);
12745
+ const resolvedVersion = effectiveVersion && versions[effectiveVersion] ? effectiveVersion : latestTag;
12746
+ const resolvedVersionData = versions[resolvedVersion];
12747
+ if (!resolvedVersionData)
12748
+ return null;
12749
+ const dist = resolvedVersionData.dist ?? {};
12750
+ const shasum = dist.shasum ?? "";
12751
+ const integrity = dist.integrity ?? "";
12752
+ let latestHash = shasum;
12753
+ let hashAlgorithm = "sha1";
12754
+ if (integrity.startsWith("sha256-")) {
12755
+ latestHash = Buffer.from(integrity.slice(7), "base64").toString("hex");
12756
+ hashAlgorithm = "sha256";
12757
+ }
12758
+ const time = data.time ?? {};
12759
+ let firstReleaseDate = null;
12760
+ const created = time.created;
12761
+ if (created) {
12762
+ firstReleaseDate = new Date(created);
12763
+ }
12764
+ return {
12765
+ name,
12766
+ resolvedVersion,
12767
+ latestHash,
12768
+ hashAlgorithm,
12769
+ firstReleaseDate,
12770
+ requestedVersionFound
12771
+ };
12772
+ } catch (e) {
12773
+ this.logger.warn("Failed to parse npm response", { name, error: String(e) });
12774
+ return null;
12775
+ }
12776
+ }
12777
+ async getPypiMetadata(name, version) {
12778
+ if (!PYPI_NAME_RE.test(name)) {
12779
+ this.logger.warn("Invalid PyPI package name rejected", { name });
12780
+ return null;
12781
+ }
12782
+ const url = `${PYPI_REGISTRY}/${name}/json`;
12783
+ try {
12784
+ const response = await fetch(url, {
12785
+ signal: AbortSignal.timeout(this.timeoutMs)
12786
+ });
12787
+ if (response.status === 404)
12788
+ return null;
12789
+ if (!response.ok) {
12790
+ this.logger.warn(`PyPI registry HTTP error: ${response.status}`, { name });
12791
+ throw new Error(`PyPI registry HTTP ${response.status}`);
12792
+ }
12793
+ const data = await response.json();
12794
+ return this.parsePypiResponse(name, data, version);
12795
+ } catch (e) {
12796
+ this.logger.warn("PyPI registry request failed", { name, error: String(e) });
12797
+ throw e;
12798
+ }
12799
+ }
12800
+ parsePypiResponse(name, data, requestedVersion) {
12801
+ try {
12802
+ const info = data.info ?? {};
12803
+ const latestTag = info.version ?? "";
12804
+ if (!latestTag)
12805
+ return null;
12806
+ const releases = data.releases ?? {};
12807
+ const requestedVersionFound = !requestedVersion || Boolean(releases[requestedVersion]?.length);
12808
+ const resolvedVersion = requestedVersion && releases[requestedVersion]?.length ? requestedVersion : latestTag;
12809
+ let latestHash = "";
12810
+ const latestFiles = releases[resolvedVersion] ?? [];
12811
+ for (const file of latestFiles) {
12812
+ const digests = file.digests ?? {};
12813
+ if (digests.sha256) {
12814
+ latestHash = digests.sha256;
12815
+ break;
12816
+ }
12817
+ }
12818
+ let firstReleaseDate = null;
12819
+ for (const [, files] of Object.entries(releases)) {
12820
+ for (const file of files) {
12821
+ const uploadTime = file.upload_time_iso_8601;
12822
+ if (uploadTime) {
12823
+ const date = new Date(uploadTime);
12824
+ if (!firstReleaseDate || date < firstReleaseDate) {
12825
+ firstReleaseDate = date;
12826
+ }
12827
+ }
12828
+ }
12829
+ }
12830
+ return {
12831
+ name,
12832
+ resolvedVersion,
12833
+ latestHash,
12834
+ hashAlgorithm: "sha256",
12835
+ firstReleaseDate,
12836
+ requestedVersionFound
12837
+ };
12838
+ } catch (e) {
12839
+ this.logger.warn("Failed to parse PyPI response", { name, error: String(e) });
12840
+ return null;
12841
+ }
12842
+ }
12843
+ };
12844
+
12845
+ // ../core/dist/clients/url-check.js
12846
+ var DEFAULT_TIMEOUT3 = 5;
12847
+ var MAX_URLS_PER_REQUEST = 50;
12848
+ var SERVICE_NAME2 = "sage";
12849
+ function getProviderTld2() {
12850
+ return "com";
12851
+ }
12852
+ var REQUEST_HEADERS2 = [
12853
+ { name: "Accept", value: "application/json" },
12854
+ { name: "Content-Type", value: "application/json" },
12855
+ { name: "User-Agent", value: SERVICE_NAME2 }
12856
+ ];
11958
12857
  function getProviderName2() {
11959
12858
  return "avast";
11960
12859
  }
12860
+ function getSubdomain() {
12861
+ return "svc";
12862
+ }
12863
+ function buildDomain() {
12864
+ return [getSubdomain(), getProviderName2(), getProviderTld2()].join(".");
12865
+ }
12866
+ function resolveEndpoint(path) {
12867
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
12868
+ return `https://${SERVICE_NAME2}-proxy.${buildDomain()}${normalizedPath}`;
12869
+ }
11961
12870
  var UrlCheckClient = class {
11962
12871
  endpoint;
11963
12872
  timeoutMs;
11964
12873
  logger;
11965
12874
  constructor(config, logger = nullLogger) {
11966
- this.endpoint = config?.endpoint ?? this.resolveEndpoint();
11967
- this.timeoutMs = (config?.timeout_seconds ?? DEFAULT_TIMEOUT2) * 1e3;
12875
+ this.endpoint = config?.endpoint ?? resolveEndpoint("/url-check");
12876
+ this.timeoutMs = (config?.timeout_seconds ?? DEFAULT_TIMEOUT3) * 1e3;
11968
12877
  this.logger = logger;
11969
12878
  }
11970
- buildPath() {
11971
- return "/url-check";
11972
- }
11973
12879
  async checkUrls(urls) {
11974
12880
  if (urls.length === 0)
11975
12881
  return [];
@@ -11980,9 +12886,6 @@ var UrlCheckClient = class {
11980
12886
  const batchResults = await Promise.all(batches.map((batch) => this.checkBatch(batch)));
11981
12887
  return batchResults.flat();
11982
12888
  }
11983
- getSubdomain() {
11984
- return "svc";
11985
- }
11986
12889
  async checkBatch(urls) {
11987
12890
  const queries = urls.map((url) => ({ key: { "url-like": url } }));
11988
12891
  const payload = {
@@ -12018,9 +12921,6 @@ var UrlCheckClient = class {
12018
12921
  return [];
12019
12922
  }
12020
12923
  }
12021
- buildDomain() {
12022
- return [this.getSubdomain(), getProviderName2(), getProviderTld2()].join(".");
12023
- }
12024
12924
  parseAnswer(answer) {
12025
12925
  try {
12026
12926
  const url = answer.key ?? "";
@@ -12045,9 +12945,6 @@ var UrlCheckClient = class {
12045
12945
  return null;
12046
12946
  }
12047
12947
  }
12048
- resolveEndpoint() {
12049
- return `https://${SERVICE_NAME2}-proxy.${this.buildDomain()}${this.buildPath()}`;
12050
- }
12051
12948
  };
12052
12949
 
12053
12950
  // ../core/dist/engine.js
@@ -12078,17 +12975,8 @@ var DecisionEngine = class {
12078
12975
  constructor(sensitivity = "balanced") {
12079
12976
  this.threshold = SENSITIVITY_THRESHOLDS[sensitivity] ?? CONFIDENCE_THRESHOLD;
12080
12977
  }
12081
- async decide(heuristicMatchesOrSources, urlCheckResults) {
12082
- let sources;
12083
- if (Array.isArray(heuristicMatchesOrSources)) {
12084
- sources = {
12085
- heuristicMatches: heuristicMatchesOrSources,
12086
- urlCheckResults: urlCheckResults ?? []
12087
- };
12088
- } else {
12089
- sources = heuristicMatchesOrSources;
12090
- }
12091
- const signals = this.collectSignals(sources.heuristicMatches, sources.urlCheckResults, sources.packageCheckResults);
12978
+ async decide(sources) {
12979
+ const signals = this.collectSignals(sources.heuristicMatches, sources.urlCheckResults, sources.packageCheckResults, sources.amsiCheckResults);
12092
12980
  if (signals.length === 0) {
12093
12981
  return this.allowVerdict();
12094
12982
  }
@@ -12115,7 +13003,7 @@ var DecisionEngine = class {
12115
13003
  reasons: allReasons
12116
13004
  };
12117
13005
  }
12118
- collectSignals(heuristicMatches, urlCheckResults, packageCheckResults) {
13006
+ collectSignals(heuristicMatches, urlCheckResults, packageCheckResults, amsiCheckResults) {
12119
13007
  const signals = [];
12120
13008
  for (const match of heuristicMatches) {
12121
13009
  signals.push({
@@ -12165,6 +13053,33 @@ var DecisionEngine = class {
12165
13053
  signals.push(signal);
12166
13054
  }
12167
13055
  }
13056
+ if (amsiCheckResults) {
13057
+ for (const result of amsiCheckResults) {
13058
+ if (result.isDetected) {
13059
+ signals.push({
13060
+ decision: "deny",
13061
+ category: "malware",
13062
+ confidence: 1,
13063
+ severity: "critical",
13064
+ source: "amsi",
13065
+ threatId: null,
13066
+ reason: `AMSI detected malware in ${result.contentName} (result=${result.amsiResult})`,
13067
+ artifact: result.contentName
13068
+ });
13069
+ } else if (result.isBlockedByAdmin) {
13070
+ signals.push({
13071
+ decision: "deny",
13072
+ category: "malware",
13073
+ confidence: 0.9,
13074
+ severity: "critical",
13075
+ source: "amsi",
13076
+ threatId: null,
13077
+ reason: `AMSI: content blocked by admin policy in ${result.contentName} (result=${result.amsiResult})`,
13078
+ artifact: result.contentName
13079
+ });
13080
+ }
13081
+ }
13082
+ }
12168
13083
  return signals;
12169
13084
  }
12170
13085
  packageVerdictToSignal(pkg) {
@@ -12346,113 +13261,566 @@ async function loadTrustedDomains(allowlistsDir, logger = nullLogger) {
12346
13261
  logger.warn(`Failed to parse ${filename}`, { error: String(e) });
12347
13262
  continue;
12348
13263
  }
12349
- if (!Array.isArray(data)) {
12350
- logger.warn(`Expected list in ${filename}, got ${typeof data}`);
13264
+ if (!Array.isArray(data)) {
13265
+ logger.warn(`Expected list in ${filename}, got ${typeof data}`);
13266
+ continue;
13267
+ }
13268
+ for (const entry of data) {
13269
+ if (typeof entry !== "object" || entry === null) {
13270
+ logger.warn(`Skipping non-object entry in ${filename}`);
13271
+ continue;
13272
+ }
13273
+ const record = entry;
13274
+ const domain = record.domain;
13275
+ const reason = record.reason;
13276
+ if (!domain || typeof domain !== "string") {
13277
+ logger.warn(`Skipping entry without valid 'domain' in ${filename}`);
13278
+ continue;
13279
+ }
13280
+ if (!reason || typeof reason !== "string") {
13281
+ logger.warn(`Skipping entry without valid 'reason' in ${filename}`);
13282
+ continue;
13283
+ }
13284
+ domains.push({ domain: domain.toLowerCase(), reason });
13285
+ }
13286
+ }
13287
+ logger.debug(`Loaded ${domains.length} trusted domains from ${allowlistsDir}`);
13288
+ return domains;
13289
+ }
13290
+ function extractDomain(url) {
13291
+ try {
13292
+ const parsed = new URL(url);
13293
+ return parsed.hostname.toLowerCase() || null;
13294
+ } catch {
13295
+ return null;
13296
+ }
13297
+ }
13298
+ function isTrustedDomain(domain, trusted) {
13299
+ const domainLower = domain.toLowerCase();
13300
+ for (const td of trusted) {
13301
+ if (domainLower === td.domain)
13302
+ return true;
13303
+ if (domainLower.endsWith(`.${td.domain}`))
13304
+ return true;
13305
+ }
13306
+ return false;
13307
+ }
13308
+
13309
+ // ../core/dist/heuristics.js
13310
+ var TRUSTED_DOMAIN_SUPPRESSIBLE = /* @__PURE__ */ new Set([
13311
+ "CLT-CMD-001",
13312
+ "CLT-CMD-002",
13313
+ "CLT-SUPPLY-001",
13314
+ "CLT-SUPPLY-004"
13315
+ ]);
13316
+ var HeuristicsEngine = class {
13317
+ threatMap = /* @__PURE__ */ new Map();
13318
+ trustedDomains;
13319
+ constructor(threats, trustedDomains) {
13320
+ this.trustedDomains = trustedDomains ?? [];
13321
+ for (const threat of threats) {
13322
+ for (const matchType of threat.matchOn) {
13323
+ const artifactType = matchType === "domain" ? "url" : matchType;
13324
+ const existing = this.threatMap.get(artifactType);
13325
+ if (existing) {
13326
+ existing.push(threat);
13327
+ } else {
13328
+ this.threatMap.set(artifactType, [threat]);
13329
+ }
13330
+ }
13331
+ }
13332
+ }
13333
+ isSuppressedByTrustedDomain(match) {
13334
+ if (this.trustedDomains.length === 0)
13335
+ return false;
13336
+ if (!TRUSTED_DOMAIN_SUPPRESSIBLE.has(match.threat.id))
13337
+ return false;
13338
+ const urls = extractUrls(match.matchValue);
13339
+ if (urls.length === 0)
13340
+ return false;
13341
+ for (const url of urls) {
13342
+ const domain = extractDomain(url);
13343
+ if (!domain)
13344
+ return false;
13345
+ if (!isTrustedDomain(domain, this.trustedDomains))
13346
+ return false;
13347
+ }
13348
+ return true;
13349
+ }
13350
+ match(artifacts) {
13351
+ const matches = [];
13352
+ for (const artifact of artifacts) {
13353
+ const threats = this.threatMap.get(artifact.type) ?? [];
13354
+ for (const threat of threats) {
13355
+ const m = threat.compiledPattern.exec(artifact.value);
13356
+ if (m) {
13357
+ matches.push({
13358
+ threat,
13359
+ artifact: artifact.value,
13360
+ matchValue: m[0]
13361
+ });
13362
+ }
13363
+ }
13364
+ }
13365
+ if (this.trustedDomains.length > 0) {
13366
+ return matches.filter((m) => !this.isSuppressedByTrustedDomain(m));
13367
+ }
13368
+ return matches;
13369
+ }
13370
+ };
13371
+
13372
+ // ../core/dist/package-checker.js
13373
+ var SUSPICIOUS_AGE_DAYS = 7;
13374
+ var MAX_CONCURRENT = 10;
13375
+ var PackageChecker = class {
13376
+ registryClient;
13377
+ fileCheckClient;
13378
+ logger;
13379
+ constructor(config = {}, logger = nullLogger) {
13380
+ this.logger = logger;
13381
+ this.registryClient = new RegistryClient({ timeout_seconds: config.registryTimeoutSeconds }, logger);
13382
+ this.fileCheckClient = config.fileCheckEnabled !== false ? new FileCheckClient({
13383
+ endpoint: config.fileCheckEndpoint,
13384
+ timeout_seconds: config.fileCheckTimeoutSeconds
13385
+ }, logger) : null;
13386
+ }
13387
+ async checkPackages(packages) {
13388
+ const toCheck = [];
13389
+ const results = [];
13390
+ for (const pkg of packages) {
13391
+ if (pkg.name.startsWith("@")) {
13392
+ results.push({
13393
+ packageName: pkg.name,
13394
+ registry: pkg.registry,
13395
+ verdict: "clean",
13396
+ confidence: 1,
13397
+ details: "Scoped package \u2014 skipped (v1)"
13398
+ });
13399
+ } else {
13400
+ toCheck.push(pkg);
13401
+ }
13402
+ }
13403
+ const checkResults = await this.runWithConcurrencyLimit(toCheck, (pkg) => this.checkSinglePackage(pkg), MAX_CONCURRENT);
13404
+ results.push(...checkResults);
13405
+ return results;
13406
+ }
13407
+ async checkSinglePackage(pkg) {
13408
+ let metadata;
13409
+ try {
13410
+ metadata = await this.registryClient.getPackageMetadata(pkg.name, pkg.registry, pkg.version);
13411
+ } catch {
13412
+ return {
13413
+ packageName: pkg.name,
13414
+ registry: pkg.registry,
13415
+ verdict: "unknown",
13416
+ confidence: 0.6,
13417
+ details: `Package check for '${pkg.name}' timed out (verify manually)`
13418
+ };
13419
+ }
13420
+ if (metadata === null) {
13421
+ return {
13422
+ packageName: pkg.name,
13423
+ registry: pkg.registry,
13424
+ verdict: "not_found",
13425
+ confidence: 0.95,
13426
+ details: `Package '${pkg.name}' not found on ${pkg.registry} (non-existent or misspelled)`
13427
+ };
13428
+ }
13429
+ if (!metadata.requestedVersionFound && pkg.version) {
13430
+ return {
13431
+ packageName: pkg.name,
13432
+ registry: pkg.registry,
13433
+ verdict: "not_found",
13434
+ confidence: 0.9,
13435
+ details: `Package '${pkg.name}@${pkg.version}' version not found on ${pkg.registry} (hallucinated or unpublished)`
13436
+ };
13437
+ }
13438
+ if (metadata.latestHash && this.fileCheckClient && metadata.hashAlgorithm === "sha256") {
13439
+ try {
13440
+ const fileResult = await this.fileCheckClient.checkHash(metadata.latestHash);
13441
+ if (fileResult) {
13442
+ const sev = fileResult.severity.toUpperCase();
13443
+ if (sev === "SEVERITY_MALWARE") {
13444
+ const detections = fileResult.detectionNames.length > 0 ? fileResult.detectionNames.join(", ") : sev;
13445
+ return {
13446
+ packageName: pkg.name,
13447
+ registry: pkg.registry,
13448
+ verdict: "malicious",
13449
+ confidence: 1,
13450
+ details: `Malicious package '${pkg.name}' (${detections})`,
13451
+ fileCheckSeverity: sev
13452
+ };
13453
+ }
13454
+ }
13455
+ } catch {
13456
+ this.logger.warn("File-check failed for package", { name: pkg.name });
13457
+ }
13458
+ }
13459
+ const ageDays = metadata.firstReleaseDate ? (Date.now() - metadata.firstReleaseDate.getTime()) / (1e3 * 60 * 60 * 24) : void 0;
13460
+ if (ageDays !== void 0 && ageDays < SUSPICIOUS_AGE_DAYS) {
13461
+ return {
13462
+ packageName: pkg.name,
13463
+ registry: pkg.registry,
13464
+ verdict: "suspicious_age",
13465
+ confidence: 0.75,
13466
+ details: `Package '${pkg.name}' first published ${Math.floor(ageDays)} days ago (suspicious age)`,
13467
+ ageDays
13468
+ };
13469
+ }
13470
+ return {
13471
+ packageName: pkg.name,
13472
+ registry: pkg.registry,
13473
+ verdict: "clean",
13474
+ confidence: 1,
13475
+ details: `Package '${pkg.name}' verified on ${pkg.registry}`,
13476
+ ageDays
13477
+ };
13478
+ }
13479
+ async runWithConcurrencyLimit(items, fn, limit) {
13480
+ const results = [];
13481
+ const executing = [];
13482
+ for (const item of items) {
13483
+ const p = fn(item).then((r) => {
13484
+ results.push(r);
13485
+ });
13486
+ executing.push(p);
13487
+ if (executing.length >= limit) {
13488
+ await Promise.race(executing);
13489
+ for (let i = executing.length - 1; i >= 0; i--) {
13490
+ const settled = await Promise.race([
13491
+ executing[i]?.then(() => true),
13492
+ Promise.resolve(false)
13493
+ ]);
13494
+ if (settled)
13495
+ executing.splice(i, 1);
13496
+ }
13497
+ }
13498
+ }
13499
+ await Promise.allSettled(executing);
13500
+ return results;
13501
+ }
13502
+ };
13503
+
13504
+ // ../core/dist/package-extractor.js
13505
+ function extractPackagesFromCommand(command) {
13506
+ const packages = [];
13507
+ const normalized = command.replace(/^(\s*(sudo|env\s+\S+=\S+)\s+)+/g, "").trim();
13508
+ const commands = normalized.split(/\s*(?:&&|;|\|)\s*/);
13509
+ for (const cmd of commands) {
13510
+ const tokens = shellTokenize(cmd.trim());
13511
+ if (tokens.length === 0)
13512
+ continue;
13513
+ const binary = tokens[0]?.replace(/^.*[/\\]/, "") ?? "";
13514
+ if (["npm", "npx"].includes(binary)) {
13515
+ packages.push(...extractNpmPackages(tokens));
13516
+ } else if (binary === "yarn") {
13517
+ packages.push(...extractYarnPackages(tokens));
13518
+ } else if (["pnpm", "bunx"].includes(binary)) {
13519
+ packages.push(...extractPnpmBunPackages(tokens, binary));
13520
+ } else if (binary === "bun") {
13521
+ packages.push(...extractBunPackages(tokens));
13522
+ } else if (["pip", "pip3"].includes(binary)) {
13523
+ packages.push(...extractPipPackages(tokens));
13524
+ } else if (["python", "python3", "python.exe", "python3.exe"].includes(binary) || binary.match(/python3?\.\d+/) || binary.endsWith("/python") || binary.endsWith("/python3")) {
13525
+ if (tokens[1] === "-m" && ["pip", "pip3"].includes(tokens[2] ?? "")) {
13526
+ packages.push(...extractPipPackages(tokens.slice(2)));
13527
+ }
13528
+ }
13529
+ }
13530
+ return packages;
13531
+ }
13532
+ function extractPackagesFromManifest(filePath, content) {
13533
+ const lowerPath = filePath.toLowerCase().replace(/\\/g, "/");
13534
+ if (lowerPath.endsWith("package.json")) {
13535
+ return extractFromPackageJson(content);
13536
+ }
13537
+ if (lowerPath.endsWith("requirements.txt")) {
13538
+ return extractFromRequirementsTxt(content);
13539
+ }
13540
+ return [];
13541
+ }
13542
+ function shellTokenize(cmd) {
13543
+ const tokens = [];
13544
+ let current = "";
13545
+ let inSingle = false;
13546
+ let inDouble = false;
13547
+ for (const ch of cmd) {
13548
+ if (ch === "'" && !inDouble) {
13549
+ inSingle = !inSingle;
13550
+ } else if (ch === '"' && !inSingle) {
13551
+ inDouble = !inDouble;
13552
+ } else if (/\s/.test(ch) && !inSingle && !inDouble) {
13553
+ if (current)
13554
+ tokens.push(current);
13555
+ current = "";
13556
+ } else {
13557
+ current += ch;
13558
+ }
13559
+ }
13560
+ if (current)
13561
+ tokens.push(current);
13562
+ return tokens;
13563
+ }
13564
+ function isPackageName(token) {
13565
+ if (token.startsWith("-"))
13566
+ return false;
13567
+ if (!/^[a-zA-Z@]/.test(token))
13568
+ return false;
13569
+ if (token.startsWith("https://") || token.startsWith("http://"))
13570
+ return false;
13571
+ if (token.startsWith("git+"))
13572
+ return false;
13573
+ if (token.startsWith("./") || token.startsWith("../") || token.startsWith("/"))
13574
+ return false;
13575
+ if (token.startsWith("file:"))
13576
+ return false;
13577
+ if (token === ".")
13578
+ return false;
13579
+ return true;
13580
+ }
13581
+ function splitNameVersion(token) {
13582
+ if (token.startsWith("@")) {
13583
+ const afterScope = token.indexOf("/");
13584
+ if (afterScope === -1)
13585
+ return { name: token };
13586
+ const rest = token.slice(afterScope + 1);
13587
+ const atIdx2 = rest.indexOf("@");
13588
+ if (atIdx2 > 0) {
13589
+ return {
13590
+ name: `${token.slice(0, afterScope + 1)}${rest.slice(0, atIdx2)}`,
13591
+ version: rest.slice(atIdx2 + 1)
13592
+ };
13593
+ }
13594
+ return { name: token };
13595
+ }
13596
+ const atIdx = token.indexOf("@");
13597
+ if (atIdx > 0) {
13598
+ return { name: token.slice(0, atIdx), version: token.slice(atIdx + 1) };
13599
+ }
13600
+ return { name: token };
13601
+ }
13602
+ var NPM_VALUE_FLAGS = /* @__PURE__ */ new Set([
13603
+ "--registry",
13604
+ "--tag",
13605
+ "--workspace",
13606
+ "-w",
13607
+ "--prefix",
13608
+ "--cache",
13609
+ "--userconfig",
13610
+ "--globalconfig"
13611
+ ]);
13612
+ function extractNpmPackages(tokens) {
13613
+ const packages = [];
13614
+ const binary = tokens[0]?.replace(/^.*[/\\]/, "") ?? "";
13615
+ if (binary === "npx") {
13616
+ for (let i = 1; i < tokens.length; i++) {
13617
+ const t = tokens[i] ?? "";
13618
+ if (t === "--" || t === "-y" || t === "--yes" || t === "-p" || t === "--package")
13619
+ continue;
13620
+ if (t.startsWith("-"))
13621
+ continue;
13622
+ if (!isPackageName(t))
13623
+ break;
13624
+ const { name, version } = splitNameVersion(t);
13625
+ packages.push({ name, version, registry: "npm", source: "command" });
13626
+ break;
13627
+ }
13628
+ return packages;
13629
+ }
13630
+ const subcommand = tokens[1] ?? "";
13631
+ if (!["install", "i", "add", "ci"].includes(subcommand))
13632
+ return packages;
13633
+ let skipNext = false;
13634
+ for (let i = 2; i < tokens.length; i++) {
13635
+ const t = tokens[i] ?? "";
13636
+ if (skipNext) {
13637
+ skipNext = false;
13638
+ continue;
13639
+ }
13640
+ if (NPM_VALUE_FLAGS.has(t)) {
13641
+ skipNext = true;
12351
13642
  continue;
12352
13643
  }
12353
- for (const entry of data) {
12354
- if (typeof entry !== "object" || entry === null) {
12355
- logger.warn(`Skipping non-object entry in ${filename}`);
12356
- continue;
12357
- }
12358
- const record = entry;
12359
- const domain = record.domain;
12360
- const reason = record.reason;
12361
- if (!domain || typeof domain !== "string") {
12362
- logger.warn(`Skipping entry without valid 'domain' in ${filename}`);
13644
+ if (t.startsWith("-"))
13645
+ continue;
13646
+ if (!isPackageName(t))
13647
+ break;
13648
+ const { name, version } = splitNameVersion(t);
13649
+ packages.push({ name, version, registry: "npm", source: "command" });
13650
+ }
13651
+ return packages;
13652
+ }
13653
+ function extractYarnPackages(tokens) {
13654
+ const packages = [];
13655
+ const subcommand = tokens[1] ?? "";
13656
+ if (subcommand !== "add")
13657
+ return packages;
13658
+ for (let i = 2; i < tokens.length; i++) {
13659
+ const t = tokens[i] ?? "";
13660
+ if (t.startsWith("-"))
13661
+ continue;
13662
+ if (!isPackageName(t))
13663
+ break;
13664
+ const { name, version } = splitNameVersion(t);
13665
+ packages.push({ name, version, registry: "npm", source: "command" });
13666
+ }
13667
+ return packages;
13668
+ }
13669
+ function extractPnpmBunPackages(tokens, binary) {
13670
+ const packages = [];
13671
+ if (binary === "bunx") {
13672
+ for (let i = 1; i < tokens.length; i++) {
13673
+ const t = tokens[i] ?? "";
13674
+ if (t.startsWith("-"))
12363
13675
  continue;
12364
- }
12365
- if (!reason || typeof reason !== "string") {
12366
- logger.warn(`Skipping entry without valid 'reason' in ${filename}`);
13676
+ if (!isPackageName(t))
13677
+ break;
13678
+ const { name, version } = splitNameVersion(t);
13679
+ packages.push({ name, version, registry: "npm", source: "command" });
13680
+ break;
13681
+ }
13682
+ return packages;
13683
+ }
13684
+ const subcommand = tokens[1] ?? "";
13685
+ if (subcommand === "dlx") {
13686
+ for (let i = 2; i < tokens.length; i++) {
13687
+ const t = tokens[i] ?? "";
13688
+ if (t.startsWith("-"))
12367
13689
  continue;
12368
- }
12369
- domains.push({ domain: domain.toLowerCase(), reason });
13690
+ if (!isPackageName(t))
13691
+ break;
13692
+ const { name, version } = splitNameVersion(t);
13693
+ packages.push({ name, version, registry: "npm", source: "command" });
13694
+ break;
12370
13695
  }
13696
+ return packages;
12371
13697
  }
12372
- logger.debug(`Loaded ${domains.length} trusted domains from ${allowlistsDir}`);
12373
- return domains;
12374
- }
12375
- function extractDomain(url) {
12376
- try {
12377
- const parsed = new URL(url);
12378
- return parsed.hostname.toLowerCase() || null;
12379
- } catch {
12380
- return null;
13698
+ if (!["add", "install", "i"].includes(subcommand))
13699
+ return packages;
13700
+ for (let i = 2; i < tokens.length; i++) {
13701
+ const t = tokens[i] ?? "";
13702
+ if (t.startsWith("-"))
13703
+ continue;
13704
+ if (!isPackageName(t))
13705
+ break;
13706
+ const { name, version } = splitNameVersion(t);
13707
+ packages.push({ name, version, registry: "npm", source: "command" });
12381
13708
  }
13709
+ return packages;
12382
13710
  }
12383
- function isTrustedDomain(domain, trusted) {
12384
- const domainLower = domain.toLowerCase();
12385
- for (const td of trusted) {
12386
- if (domainLower === td.domain)
12387
- return true;
12388
- if (domainLower.endsWith(`.${td.domain}`))
12389
- return true;
13711
+ function extractBunPackages(tokens) {
13712
+ const packages = [];
13713
+ const subcommand = tokens[1] ?? "";
13714
+ if (!["add", "install", "i"].includes(subcommand))
13715
+ return packages;
13716
+ for (let i = 2; i < tokens.length; i++) {
13717
+ const t = tokens[i] ?? "";
13718
+ if (t.startsWith("-"))
13719
+ continue;
13720
+ if (!isPackageName(t))
13721
+ break;
13722
+ const { name, version } = splitNameVersion(t);
13723
+ packages.push({ name, version, registry: "npm", source: "command" });
12390
13724
  }
12391
- return false;
13725
+ return packages;
12392
13726
  }
12393
-
12394
- // ../core/dist/heuristics.js
12395
- var TRUSTED_DOMAIN_SUPPRESSIBLE = /* @__PURE__ */ new Set([
12396
- "CLT-CMD-001",
12397
- "CLT-CMD-002",
12398
- "CLT-SUPPLY-001",
12399
- "CLT-SUPPLY-004"
12400
- ]);
12401
- var HeuristicsEngine = class {
12402
- threatMap = /* @__PURE__ */ new Map();
12403
- trustedDomains;
12404
- constructor(threats, trustedDomains) {
12405
- this.trustedDomains = trustedDomains ?? [];
12406
- for (const threat of threats) {
12407
- for (const matchType of threat.matchOn) {
12408
- const artifactType = matchType === "domain" ? "url" : matchType;
12409
- const existing = this.threatMap.get(artifactType);
12410
- if (existing) {
12411
- existing.push(threat);
12412
- } else {
12413
- this.threatMap.set(artifactType, [threat]);
12414
- }
12415
- }
13727
+ function extractPipPackages(tokens) {
13728
+ const packages = [];
13729
+ const subcommand = tokens[1] ?? "";
13730
+ if (subcommand !== "install")
13731
+ return packages;
13732
+ let skipNext = false;
13733
+ for (let i = 2; i < tokens.length; i++) {
13734
+ const t = tokens[i] ?? "";
13735
+ if (skipNext) {
13736
+ skipNext = false;
13737
+ continue;
13738
+ }
13739
+ if ([
13740
+ "-r",
13741
+ "--requirement",
13742
+ "-c",
13743
+ "--constraint",
13744
+ "-e",
13745
+ "--editable",
13746
+ "-t",
13747
+ "--target",
13748
+ "--prefix",
13749
+ "-i",
13750
+ "--index-url",
13751
+ "--extra-index-url",
13752
+ "-f",
13753
+ "--find-links"
13754
+ ].includes(t)) {
13755
+ skipNext = true;
13756
+ continue;
13757
+ }
13758
+ if (t.startsWith("-"))
13759
+ continue;
13760
+ if (!isPackageName(t))
13761
+ break;
13762
+ const match = t.match(/^([a-zA-Z0-9._-]+)(?:[><=!~]+(.+))?$/);
13763
+ if (match) {
13764
+ packages.push({
13765
+ name: match[1],
13766
+ version: match[2],
13767
+ registry: "pypi",
13768
+ source: "command"
13769
+ });
12416
13770
  }
12417
13771
  }
12418
- isSuppressedByTrustedDomain(match) {
12419
- if (this.trustedDomains.length === 0)
12420
- return false;
12421
- if (!TRUSTED_DOMAIN_SUPPRESSIBLE.has(match.threat.id))
12422
- return false;
12423
- const urls = extractUrls(match.matchValue);
12424
- if (urls.length === 0)
12425
- return false;
12426
- for (const url of urls) {
12427
- const domain = extractDomain(url);
12428
- if (!domain)
12429
- return false;
12430
- if (!isTrustedDomain(domain, this.trustedDomains))
12431
- return false;
13772
+ return packages;
13773
+ }
13774
+ function extractFromPackageJson(content) {
13775
+ const packages = [];
13776
+ try {
13777
+ const data = JSON.parse(content);
13778
+ const deps = data.dependencies ?? {};
13779
+ const devDeps = data.devDependencies ?? {};
13780
+ for (const [name, version] of Object.entries({ ...deps, ...devDeps })) {
13781
+ if (typeof version !== "string")
13782
+ continue;
13783
+ if (version.startsWith("file:") || version.startsWith("link:"))
13784
+ continue;
13785
+ if (version.startsWith("git+") || version.startsWith("http"))
13786
+ continue;
13787
+ packages.push({
13788
+ name,
13789
+ version: version.replace(/^[\^~>=<]+/, ""),
13790
+ registry: "npm",
13791
+ source: "package.json"
13792
+ });
12432
13793
  }
12433
- return true;
13794
+ } catch {
12434
13795
  }
12435
- match(artifacts) {
12436
- const matches = [];
12437
- for (const artifact of artifacts) {
12438
- const threats = this.threatMap.get(artifact.type) ?? [];
12439
- for (const threat of threats) {
12440
- const m = threat.compiledPattern.exec(artifact.value);
12441
- if (m) {
12442
- matches.push({
12443
- threat,
12444
- artifact: artifact.value,
12445
- matchValue: m[0]
12446
- });
12447
- }
12448
- }
13796
+ return packages;
13797
+ }
13798
+ function extractFromRequirementsTxt(content) {
13799
+ const packages = [];
13800
+ const lines = content.split("\n");
13801
+ for (const rawLine of lines) {
13802
+ const line = rawLine.trim();
13803
+ if (!line || line.startsWith("#") || line.startsWith("-r") || line.startsWith("--requirement") || line.startsWith("-e") || line.startsWith("--editable") || line.startsWith("-i") || line.startsWith("--index-url") || line.startsWith("--extra-index-url") || line.startsWith("-f") || line.startsWith("--find-links") || line.startsWith("-c") || line.startsWith("--constraint")) {
13804
+ continue;
12449
13805
  }
12450
- if (this.trustedDomains.length > 0) {
12451
- return matches.filter((m) => !this.isSuppressedByTrustedDomain(m));
13806
+ if (line.startsWith("git+") || line.startsWith("http://") || line.startsWith("https://")) {
13807
+ continue;
13808
+ }
13809
+ const cleaned = line.split("#")[0]?.split(";")[0]?.trim() ?? "";
13810
+ if (!cleaned)
13811
+ continue;
13812
+ const match = cleaned.match(/^([a-zA-Z0-9._-]+)(?:\[.*\])?(?:[><=!~]+(.+))?$/);
13813
+ if (match) {
13814
+ packages.push({
13815
+ name: match[1],
13816
+ version: match[2]?.trim(),
13817
+ registry: "pypi",
13818
+ source: "requirements.txt"
13819
+ });
12452
13820
  }
12453
- return matches;
12454
13821
  }
12455
- };
13822
+ return packages;
13823
+ }
12456
13824
 
12457
13825
  // ../core/dist/threat-loader.js
12458
13826
  var import_promises3 = require("node:fs/promises");
@@ -12560,6 +13928,249 @@ async function loadThreats(threatDir, logger = nullLogger) {
12560
13928
  return threats;
12561
13929
  }
12562
13930
 
13931
+ // ../core/dist/evaluator.js
13932
+ function allowVerdict(source = "none") {
13933
+ return {
13934
+ decision: "allow",
13935
+ category: "none",
13936
+ confidence: 1,
13937
+ severity: "info",
13938
+ source,
13939
+ artifacts: [],
13940
+ matchedThreatId: null,
13941
+ reasons: []
13942
+ };
13943
+ }
13944
+ async function evaluateToolCall(request, context) {
13945
+ const logger = context.logger ?? nullLogger;
13946
+ const config = await loadConfig(context.configPath, logger).catch(() => ConfigSchema.parse({}));
13947
+ if (request.artifacts.length === 0) {
13948
+ return allowVerdict("no_artifacts");
13949
+ }
13950
+ try {
13951
+ const allowlist = await loadAllowlist(config.allowlist, logger);
13952
+ if (isAllowlisted(allowlist, request.artifacts)) {
13953
+ const verdict2 = allowVerdict("allowlisted");
13954
+ await logVerdict(config.logging, request.sessionId, request.toolName, request.toolInput, verdict2, true);
13955
+ return verdict2;
13956
+ }
13957
+ } catch {
13958
+ }
13959
+ let cache = null;
13960
+ try {
13961
+ cache = new VerdictCache(config.cache, logger);
13962
+ await cache.load();
13963
+ } catch {
13964
+ cache = null;
13965
+ }
13966
+ const urls = request.artifacts.filter((artifact) => artifact.type === "url").map((artifact) => artifact.value);
13967
+ const { cachedUrlVerdicts, uncachedUrls } = partitionUrlsByCache(urls, cache);
13968
+ let heuristicMatches = [];
13969
+ if (config.heuristics_enabled) {
13970
+ let threats = await loadThreats(context.threatsDir, logger);
13971
+ if (config.disabled_threats.length > 0) {
13972
+ const disabledSet = new Set(config.disabled_threats);
13973
+ threats = threats.filter((t) => !disabledSet.has(t.id));
13974
+ }
13975
+ const trustedDomains = await loadTrustedDomains(context.allowlistsDir, logger);
13976
+ const heuristics = new HeuristicsEngine(threats, trustedDomains);
13977
+ heuristicMatches = heuristics.match(request.artifacts);
13978
+ }
13979
+ let urlCheckResults = [];
13980
+ const urlsToCheck = cache ? uncachedUrls : urls;
13981
+ if (urlsToCheck.length > 0 && config.url_check.enabled) {
13982
+ try {
13983
+ const client = new UrlCheckClient(config.url_check, logger);
13984
+ urlCheckResults = await client.checkUrls(urlsToCheck);
13985
+ } catch {
13986
+ }
13987
+ }
13988
+ const packageCheckResults = await checkPackages(request, config, cache, logger);
13989
+ const amsiCheckResults = [];
13990
+ if (config.amsi_check.enabled && isAmsiSupported()) {
13991
+ let amsiClient = null;
13992
+ try {
13993
+ amsiClient = new AmsiClient(logger);
13994
+ await amsiClient.init();
13995
+ if (amsiClient.isAvailable) {
13996
+ const scans = [];
13997
+ if (request.toolName === "Bash") {
13998
+ const command = request.toolInput.command ?? "";
13999
+ if (command) {
14000
+ scans.push({ content: command, name: `Bash:command` });
14001
+ }
14002
+ } else if (request.toolName === "Write") {
14003
+ const filePath = request.toolInput.file_path ?? "";
14004
+ const content = request.toolInput.content ?? "";
14005
+ if (content) {
14006
+ scans.push({ content, name: `Write:${filePath}` });
14007
+ }
14008
+ } else if (request.toolName === "Edit") {
14009
+ const filePath = request.toolInput.file_path ?? "";
14010
+ const newString = request.toolInput.new_string ?? "";
14011
+ if (newString) {
14012
+ scans.push({ content: newString, name: `Edit:${filePath}` });
14013
+ }
14014
+ }
14015
+ for (const scan of scans) {
14016
+ const result = await amsiClient.scanString(scan.content, scan.name);
14017
+ if (result) {
14018
+ amsiCheckResults.push(result);
14019
+ }
14020
+ }
14021
+ }
14022
+ } catch {
14023
+ } finally {
14024
+ amsiClient?.close();
14025
+ }
14026
+ }
14027
+ const engine = new DecisionEngine(config.sensitivity);
14028
+ let verdict = await engine.decide({
14029
+ heuristicMatches,
14030
+ urlCheckResults,
14031
+ packageCheckResults: packageCheckResults.length > 0 ? packageCheckResults : void 0,
14032
+ amsiCheckResults: amsiCheckResults.length > 0 ? amsiCheckResults : void 0
14033
+ });
14034
+ if (cachedUrlVerdicts.size > 0 && verdict.decision === "allow") {
14035
+ for (const [url, cachedVerdict] of cachedUrlVerdicts) {
14036
+ if (cachedVerdict.verdict === "allow") {
14037
+ continue;
14038
+ }
14039
+ verdict = {
14040
+ decision: cachedVerdict.verdict,
14041
+ category: "network_egress",
14042
+ confidence: 1,
14043
+ severity: cachedVerdict.severity,
14044
+ source: `cache(${cachedVerdict.source})`,
14045
+ artifacts: [url],
14046
+ matchedThreatId: null,
14047
+ reasons: cachedVerdict.reasons
14048
+ };
14049
+ break;
14050
+ }
14051
+ }
14052
+ await cacheUrlResults(urlCheckResults, cache);
14053
+ try {
14054
+ await logVerdict(config.logging, request.sessionId, request.toolName, request.toolInput, verdict);
14055
+ } catch {
14056
+ }
14057
+ return verdict;
14058
+ }
14059
+ function partitionUrlsByCache(urls, cache) {
14060
+ const cachedUrlVerdicts = /* @__PURE__ */ new Map();
14061
+ let uncachedUrls = [];
14062
+ if (cache && urls.length > 0) {
14063
+ try {
14064
+ for (const url of urls) {
14065
+ const cached = cache.getUrl(url);
14066
+ if (cached !== null) {
14067
+ cachedUrlVerdicts.set(url, cached);
14068
+ } else {
14069
+ uncachedUrls.push(url);
14070
+ }
14071
+ }
14072
+ } catch {
14073
+ uncachedUrls = urls;
14074
+ }
14075
+ }
14076
+ return { cachedUrlVerdicts, uncachedUrls };
14077
+ }
14078
+ async function cacheUrlResults(urlCheckResults, cache) {
14079
+ if (!cache)
14080
+ return;
14081
+ try {
14082
+ for (const result of urlCheckResults) {
14083
+ let cachedVerdict;
14084
+ if (result.isMalicious) {
14085
+ cachedVerdict = {
14086
+ verdict: "deny",
14087
+ severity: "critical",
14088
+ reasons: [
14089
+ `URL check: malicious (${result.findings.map((finding) => `${finding.severityName}/${finding.typeName}`).join(", ")})`
14090
+ ],
14091
+ source: "url_check"
14092
+ };
14093
+ } else if (result.flags.length > 0) {
14094
+ cachedVerdict = {
14095
+ verdict: "ask",
14096
+ severity: "warning",
14097
+ reasons: [`URL check: suspicious (${result.flags.join(", ")})`],
14098
+ source: "url_check"
14099
+ };
14100
+ } else {
14101
+ cachedVerdict = {
14102
+ verdict: "allow",
14103
+ severity: "info",
14104
+ reasons: [],
14105
+ source: "url_check"
14106
+ };
14107
+ }
14108
+ cache.putUrl(result.url, cachedVerdict, result.isMalicious);
14109
+ }
14110
+ await cache.save();
14111
+ } catch {
14112
+ }
14113
+ }
14114
+ async function checkPackages(request, config, cache, logger) {
14115
+ const results = [];
14116
+ if (!config.package_check.enabled)
14117
+ return results;
14118
+ try {
14119
+ let parsedPackages;
14120
+ if (request.toolName === "Bash") {
14121
+ const command = request.toolInput.command ?? "";
14122
+ parsedPackages = extractPackagesFromCommand(command);
14123
+ } else if (request.toolName === "Write" || request.toolName === "Edit") {
14124
+ const filePath = request.toolInput.file_path ?? "";
14125
+ const content = request.toolInput.content ?? request.toolInput.new_string ?? "";
14126
+ parsedPackages = extractPackagesFromManifest(filePath, content);
14127
+ }
14128
+ if (!parsedPackages || parsedPackages.length === 0)
14129
+ return results;
14130
+ const uncached = [];
14131
+ for (const pkg of parsedPackages) {
14132
+ const cacheKey2 = `${pkg.registry}:${pkg.name}${pkg.version ? `@${pkg.version}` : ""}`;
14133
+ const cached = cache?.getPackage(cacheKey2);
14134
+ if (cached && cached.verdict !== "allow") {
14135
+ results.push({
14136
+ packageName: pkg.name,
14137
+ registry: pkg.registry,
14138
+ verdict: cached.verdict === "deny" ? "malicious" : "suspicious_age",
14139
+ confidence: 1,
14140
+ details: cached.reasons.join("; ")
14141
+ });
14142
+ } else if (!cached) {
14143
+ uncached.push(pkg);
14144
+ }
14145
+ }
14146
+ if (uncached.length > 0) {
14147
+ const checker = new PackageChecker({
14148
+ registryTimeoutSeconds: config.package_check.timeout_seconds,
14149
+ fileCheckEndpoint: config.file_check.endpoint,
14150
+ fileCheckTimeoutSeconds: config.file_check.timeout_seconds,
14151
+ fileCheckEnabled: config.file_check.enabled
14152
+ }, logger);
14153
+ const checked = await checker.checkPackages(uncached);
14154
+ results.push(...checked);
14155
+ if (cache) {
14156
+ for (const result of checked) {
14157
+ const pkg = uncached.find((p) => p.name === result.packageName);
14158
+ const cacheKey2 = `${result.registry}:${result.packageName}${pkg?.version ? `@${pkg.version}` : ""}`;
14159
+ const isCritical = result.verdict === "malicious" || result.verdict === "not_found";
14160
+ cache.putPackage(cacheKey2, {
14161
+ verdict: result.verdict === "clean" ? "allow" : isCritical ? "deny" : "ask",
14162
+ severity: isCritical ? "critical" : "warning",
14163
+ reasons: [result.details],
14164
+ source: "package_check"
14165
+ }, result.ageDays ?? null);
14166
+ }
14167
+ }
14168
+ }
14169
+ } catch {
14170
+ }
14171
+ return results;
14172
+ }
14173
+
12563
14174
  // ../core/dist/format.js
12564
14175
  var PAD = 12;
12565
14176
  var SEPARATOR_WIDTH = 48;
@@ -12580,6 +14191,14 @@ function separatorLine(headerLength) {
12580
14191
  function formatUpdateNotice(result) {
12581
14192
  return `\u2B06\uFE0F Update available: v${result.currentVersion} \u2192 v${result.latestVersion} (https://github.com/avast/sage)`;
12582
14193
  }
14194
+ function formatStartupClean(version, versionCheck) {
14195
+ const base = `\u{1F6E1}\uFE0F Sage v${version} by Gen Digital \u2705 No threats found`;
14196
+ if (versionCheck?.updateAvailable) {
14197
+ return `${base}
14198
+ ${formatUpdateNotice(versionCheck)}`;
14199
+ }
14200
+ return base;
14201
+ }
12583
14202
  function formatThreatBanner(version, results, versionCheck) {
12584
14203
  const header = `\u{1F6E1}\uFE0F Sage v${version} by Gen Digital \u2014 Threat Detected`;
12585
14204
  const lines = [" ", header, separatorLine(SEPARATOR_WIDTH)];
@@ -12616,31 +14235,203 @@ function formatThreatBanner(version, results, versionCheck) {
12616
14235
  }
12617
14236
  return lines.join("\n");
12618
14237
  }
14238
+ function formatSessionStartMessage(version, result) {
14239
+ if (result.scanResults.length > 0) {
14240
+ return formatThreatBanner(version, result.scanResults, result.versionCheck);
14241
+ }
14242
+ return formatStartupClean(version, result.versionCheck);
14243
+ }
12619
14244
 
12620
- // ../core/dist/plugin-scan-cache.js
12621
- var import_node_crypto3 = require("node:crypto");
14245
+ // ../core/dist/guard.js
14246
+ async function guardToolCall(request, context, approvalStore) {
14247
+ const actionId = ApprovalStore.actionId(request.toolName, request.toolInput, request.sessionId);
14248
+ if (approvalStore.isApproved(actionId)) {
14249
+ return { verdict: allowVerdict("approved") };
14250
+ }
14251
+ const verdict = await evaluateToolCall(request, context);
14252
+ if (verdict.decision === "allow" || verdict.decision === "deny") {
14253
+ return { verdict };
14254
+ }
14255
+ const logger = context.logger ?? nullLogger;
14256
+ const config = await loadConfig(context.configPath, logger);
14257
+ if (config.sensitivity === "paranoid") {
14258
+ return { verdict: { ...verdict, decision: "deny" } };
14259
+ }
14260
+ approvalStore.setPending(actionId, {
14261
+ artifacts: request.artifacts,
14262
+ createdAt: Date.now()
14263
+ });
14264
+ return { verdict, actionId };
14265
+ }
14266
+ var MAX_ARTIFACT_VALUE_LEN = 120;
14267
+ function truncateValue(value) {
14268
+ const singleLine = value.replaceAll("\n", "\\n");
14269
+ if (singleLine.length <= MAX_ARTIFACT_VALUE_LEN)
14270
+ return singleLine;
14271
+ return `${singleLine.slice(0, MAX_ARTIFACT_VALUE_LEN)}\u2026`;
14272
+ }
14273
+ function summarizeArtifacts(artifacts) {
14274
+ if (artifacts.length === 0)
14275
+ return "none";
14276
+ return artifacts.slice(0, 3).map((artifact) => `${artifact.type} '${truncateValue(artifact.value)}'`).join(", ");
14277
+ }
14278
+ function formatDenyMessage(verdict) {
14279
+ const reasons = verdict.reasons.length > 0 ? verdict.reasons.slice(0, 5).join("; ") : verdict.category;
14280
+ return [
14281
+ "Sage blocked this action.",
14282
+ `Severity: ${verdict.severity}`,
14283
+ `Category: ${verdict.category}`,
14284
+ `Reason: ${reasons}`
14285
+ ].join("\n");
14286
+ }
14287
+ function formatAskMessage(actionId, verdict, artifacts) {
14288
+ const reasons = verdict.reasons.length > 0 ? verdict.reasons.slice(0, 3).join("; ") : verdict.category;
14289
+ return [
14290
+ "Sage flagged this action and requires explicit user approval.",
14291
+ `Severity: ${verdict.severity}`,
14292
+ `Category: ${verdict.category}`,
14293
+ `Reason: ${reasons}`,
14294
+ `Artifacts: ${summarizeArtifacts(artifacts)}`,
14295
+ "",
14296
+ "Ask the user for confirmation to proceed, DO NOT auto-approve.",
14297
+ `if the user confirms, call:`,
14298
+ ` sage_approve({ actionId: "${actionId}", approved: true })`,
14299
+ "If the user declines, call:",
14300
+ ` sage_approve({ actionId: "${actionId}", approved: false })`
14301
+ ].join("\n");
14302
+ }
14303
+ async function approveAction(store, actionId) {
14304
+ const entry = store.approve(actionId);
14305
+ if (!entry) {
14306
+ return "No pending Sage approval found for this action ID.";
14307
+ }
14308
+ return [
14309
+ `Approved action ${actionId}. Retry the original tool call now.`,
14310
+ "",
14311
+ "Inform the user that they can ask you to permanently allowlist this artifact. Do NOT add it to the allowlist unless the user explicitly requests it."
14312
+ ].join("\n");
14313
+ }
14314
+ async function addToAllowlist(store, type, value, reason, configPath, logger = nullLogger) {
14315
+ if (!store.hasApprovedArtifact(type, value)) {
14316
+ return "Cannot add to allowlist: no recent user approval found for this artifact.";
14317
+ }
14318
+ const config = await loadConfig(configPath, logger);
14319
+ const allowlist = await loadAllowlist(config.allowlist, logger);
14320
+ const entryReason = reason ?? "Approved by user via sage_approve";
14321
+ if (type === "url") {
14322
+ addUrl(allowlist, value, entryReason, "ask");
14323
+ } else if (type === "command") {
14324
+ addCommand(allowlist, value, entryReason, "ask");
14325
+ } else {
14326
+ addFilePath(allowlist, value, entryReason, "ask");
14327
+ }
14328
+ const saved = await saveAllowlist(allowlist, config.allowlist, logger);
14329
+ if (!saved) {
14330
+ return "Failed to save allowlist. Approval preserved \u2014 retry is safe.";
14331
+ }
14332
+ store.consumeApprovedArtifact(type, value);
14333
+ return `Added ${type} to Sage allowlist.`;
14334
+ }
14335
+ async function removeFromAllowlist(type, value, configPath, logger = nullLogger) {
14336
+ const config = await loadConfig(configPath, logger);
14337
+ const allowlist = await loadAllowlist(config.allowlist, logger);
14338
+ let removed;
14339
+ let removedViaHash = false;
14340
+ switch (type) {
14341
+ case "url":
14342
+ removed = removeUrl(allowlist, value);
14343
+ break;
14344
+ case "command":
14345
+ removed = removeCommand(allowlist, value);
14346
+ if (!removed) {
14347
+ removed = removeCommand(allowlist, hashCommand(value));
14348
+ removedViaHash = true;
14349
+ }
14350
+ break;
14351
+ case "file_path":
14352
+ removed = removeFilePath(allowlist, value);
14353
+ break;
14354
+ }
14355
+ if (!removed) {
14356
+ return "Artifact not found in Sage allowlist.";
14357
+ }
14358
+ const saved = await saveAllowlist(allowlist, config.allowlist, logger);
14359
+ if (!saved) {
14360
+ return "Failed to save allowlist after removal. Please retry.";
14361
+ }
14362
+ let rendered;
14363
+ switch (type) {
14364
+ case "url":
14365
+ rendered = normalizeUrl(value);
14366
+ break;
14367
+ case "command":
14368
+ rendered = `${(removedViaHash ? hashCommand(value) : value).slice(0, 12)}...`;
14369
+ break;
14370
+ case "file_path":
14371
+ rendered = normalizeFilePath(value);
14372
+ break;
14373
+ }
14374
+ return `Removed ${type} from Sage allowlist: ${rendered}`;
14375
+ }
14376
+
14377
+ // ../core/dist/installation-id.js
14378
+ var import_node_crypto4 = require("node:crypto");
12622
14379
  var import_promises4 = require("node:fs/promises");
12623
- var import_node_os3 = require("node:os");
12624
14380
  var import_node_path7 = require("node:path");
12625
- var DEFAULT_CACHE_PATH = (0, import_node_path7.join)((0, import_node_os3.homedir)(), ".sage", "plugin_scan_cache.json");
14381
+ async function getInstallationId(sageDirPath) {
14382
+ const sageDir = sageDirPath ?? resolvePath("~/.sage");
14383
+ const idPath = (0, import_node_path7.join)(sageDir, "installation-id");
14384
+ let fileExists = false;
14385
+ try {
14386
+ const existing = await (0, import_promises4.readFile)(idPath, "utf-8");
14387
+ const trimmed = existing.trim();
14388
+ if (trimmed.length > 0)
14389
+ return trimmed;
14390
+ fileExists = true;
14391
+ } catch {
14392
+ }
14393
+ try {
14394
+ const id = (0, import_node_crypto4.randomUUID)();
14395
+ await (0, import_promises4.mkdir)(sageDir, { recursive: true, mode: 448 });
14396
+ await (0, import_promises4.writeFile)(idPath, id, { encoding: "utf-8", mode: 384, flag: fileExists ? "w" : "wx" });
14397
+ return id;
14398
+ } catch (err) {
14399
+ if (err.code === "EEXIST") {
14400
+ try {
14401
+ const existing = await (0, import_promises4.readFile)(idPath, "utf-8");
14402
+ return existing.trim() || void 0;
14403
+ } catch {
14404
+ return void 0;
14405
+ }
14406
+ }
14407
+ return void 0;
14408
+ }
14409
+ }
14410
+
14411
+ // ../core/dist/plugin-scan-cache.js
14412
+ var import_node_crypto5 = require("node:crypto");
14413
+ var import_promises5 = require("node:fs/promises");
14414
+ var import_node_os3 = require("node:os");
14415
+ var import_node_path8 = require("node:path");
14416
+ var DEFAULT_CACHE_PATH = (0, import_node_path8.join)((0, import_node_os3.homedir)(), ".sage", "plugin_scan_cache.json");
12626
14417
  var CACHE_TTL_DAYS = 7;
12627
14418
  function cacheKey(pluginKey, version, lastUpdated) {
12628
14419
  return `${pluginKey}:${version}:${lastUpdated}`;
12629
14420
  }
12630
14421
  async function computeConfigHash(sageVersion, ...dirs) {
12631
- const h = (0, import_node_crypto3.createHash)("sha256");
14422
+ const h = (0, import_node_crypto5.createHash)("sha256");
12632
14423
  if (sageVersion)
12633
14424
  h.update(sageVersion);
12634
14425
  for (const dir of dirs) {
12635
14426
  let files;
12636
14427
  try {
12637
- files = (await (0, import_promises4.readdir)(dir)).filter((f) => f.endsWith(".yaml")).sort();
14428
+ files = (await (0, import_promises5.readdir)(dir)).filter((f) => f.endsWith(".yaml")).sort();
12638
14429
  } catch {
12639
14430
  continue;
12640
14431
  }
12641
14432
  for (const file of files) {
12642
14433
  try {
12643
- const content = await getFileContent((0, import_node_path7.join)(dir, file));
14434
+ const content = await getFileContent((0, import_node_path8.join)(dir, file));
12644
14435
  h.update(content);
12645
14436
  } catch {
12646
14437
  }
@@ -12683,7 +14474,6 @@ async function loadScanCache(configHash = "", cachePath = DEFAULT_CACHE_PATH, lo
12683
14474
  }
12684
14475
  async function saveScanCache(cache, cachePath = DEFAULT_CACHE_PATH, logger = nullLogger) {
12685
14476
  try {
12686
- await (0, import_promises4.mkdir)((0, import_node_path7.dirname)(cachePath), { recursive: true });
12687
14477
  const data = {
12688
14478
  config_hash: cache.configHash,
12689
14479
  entries: Object.fromEntries(Object.entries(cache.entries).map(([key, entry]) => [
@@ -12696,7 +14486,7 @@ async function saveScanCache(cache, cachePath = DEFAULT_CACHE_PATH, logger = nul
12696
14486
  }
12697
14487
  ]))
12698
14488
  };
12699
- await (0, import_promises4.writeFile)(cachePath, JSON.stringify(data, null, 2));
14489
+ await atomicWriteJson(cachePath, data);
12700
14490
  } catch (e) {
12701
14491
  logger.warn(`Failed to save scan cache to ${cachePath}`, { error: String(e) });
12702
14492
  }
@@ -12731,11 +14521,11 @@ function storeResult(cache, pluginKey, version, lastUpdated, findings) {
12731
14521
  }
12732
14522
 
12733
14523
  // ../core/dist/plugin-scanner.js
12734
- var import_node_crypto4 = require("node:crypto");
12735
- var import_promises5 = require("node:fs/promises");
14524
+ var import_node_crypto6 = require("node:crypto");
14525
+ var import_promises6 = require("node:fs/promises");
12736
14526
  var import_node_os4 = require("node:os");
12737
- var import_node_path8 = require("node:path");
12738
- var DEFAULT_PLUGINS_REGISTRY = (0, import_node_path8.join)((0, import_node_os4.homedir)(), ".claude", "plugins", "installed_plugins.json");
14527
+ var import_node_path9 = require("node:path");
14528
+ var DEFAULT_PLUGINS_REGISTRY = (0, import_node_path9.join)((0, import_node_os4.homedir)(), ".claude", "plugins", "installed_plugins.json");
12739
14529
  var SCANNABLE_EXTENSIONS = /* @__PURE__ */ new Set([
12740
14530
  ".py",
12741
14531
  ".js",
@@ -12757,38 +14547,38 @@ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "__pycache__"])
12757
14547
  var MAX_FILE_SIZE = 512 * 1024;
12758
14548
  async function walkPluginFiles(installPath, logger) {
12759
14549
  const files = [];
12760
- async function walk(dir) {
12761
- let entries;
14550
+ async function walk(dirOrFile) {
14551
+ let stats;
12762
14552
  try {
12763
- entries = await (0, import_promises5.readdir)(dir);
14553
+ stats = await (0, import_promises6.stat)(dirOrFile);
12764
14554
  } catch {
12765
14555
  return;
12766
14556
  }
12767
- for (const entry of entries) {
12768
- if (SKIP_DIRS.has(entry))
12769
- continue;
12770
- const fullPath = (0, import_node_path8.join)(dir, entry);
12771
- let stats;
14557
+ if (stats.isFile()) {
14558
+ if (SCANNABLE_EXTENSIONS.has((0, import_node_path9.extname)(dirOrFile).toLowerCase()) && stats.size <= MAX_FILE_SIZE) {
14559
+ files.push(dirOrFile);
14560
+ }
14561
+ return;
14562
+ }
14563
+ if (stats.isDirectory()) {
14564
+ let entries;
12772
14565
  try {
12773
- stats = await (0, import_promises5.stat)(fullPath);
14566
+ entries = await (0, import_promises6.readdir)(dirOrFile);
12774
14567
  } catch {
12775
- continue;
14568
+ return;
12776
14569
  }
12777
- if (stats.isDirectory()) {
12778
- await walk(fullPath);
12779
- } else if (stats.isFile()) {
12780
- if (!SCANNABLE_EXTENSIONS.has((0, import_node_path8.extname)(fullPath).toLowerCase()))
14570
+ for (const entry of entries) {
14571
+ if (SKIP_DIRS.has(entry))
12781
14572
  continue;
12782
- if (stats.size > MAX_FILE_SIZE)
12783
- continue;
12784
- files.push(fullPath);
14573
+ const fullPath = (0, import_node_path9.join)(dirOrFile, entry);
14574
+ await walk(fullPath);
12785
14575
  }
12786
14576
  }
12787
14577
  }
12788
14578
  try {
12789
14579
  await walk(installPath);
12790
14580
  } catch (e) {
12791
- logger.warn(`Error walking plugin directory ${installPath}`, { error: String(e) });
14581
+ logger.warn(`Error walking plugin path ${installPath}`, { error: String(e) });
12792
14582
  }
12793
14583
  return files;
12794
14584
  }
@@ -12806,7 +14596,7 @@ function extractArtifactsFromFile(filePath, content) {
12806
14596
  continue;
12807
14597
  artifacts.push({ type: "url", value: url, context: `plugin_file:${fileName}` });
12808
14598
  }
12809
- const ext = (0, import_node_path8.extname)(filePath).toLowerCase();
14599
+ const ext = (0, import_node_path9.extname)(filePath).toLowerCase();
12810
14600
  if ([".sh", ".bash", ".zsh", ".py"].includes(ext)) {
12811
14601
  for (const line of content.split("\n")) {
12812
14602
  const trimmed = line.trim();
@@ -12851,7 +14641,7 @@ async function scanPlugin(plugin, threats, options = {}) {
12851
14641
  confidence: match.threat.confidence,
12852
14642
  action: match.threat.action,
12853
14643
  artifact: match.artifact.slice(0, 200),
12854
- sourceFile: (0, import_node_path8.relative)(plugin.installPath, filePath)
14644
+ sourceFile: (0, import_node_path9.relative)(plugin.installPath, filePath)
12855
14645
  });
12856
14646
  }
12857
14647
  }
@@ -12859,7 +14649,7 @@ async function scanPlugin(plugin, threats, options = {}) {
12859
14649
  allUrls.push(...extractUrls(content));
12860
14650
  }
12861
14651
  if (checkFileHashes) {
12862
- const sha256 = (0, import_node_crypto4.createHash)("sha256").update(rawBytes).digest("hex");
14652
+ const sha256 = (0, import_node_crypto6.createHash)("sha256").update(rawBytes).digest("hex");
12863
14653
  const existing = hashToFiles.get(sha256);
12864
14654
  if (existing) {
12865
14655
  existing.push(filePath);
@@ -12906,7 +14696,7 @@ async function scanPlugin(plugin, threats, options = {}) {
12906
14696
  confidence: 1,
12907
14697
  action: "block",
12908
14698
  artifact: fr.sha256,
12909
- sourceFile: (0, import_node_path8.relative)(plugin.installPath, filePath)
14699
+ sourceFile: (0, import_node_path9.relative)(plugin.installPath, filePath)
12910
14700
  });
12911
14701
  }
12912
14702
  }
@@ -12942,18 +14732,63 @@ function toFindingData(finding) {
12942
14732
  };
12943
14733
  }
12944
14734
  function toAuditFindingData(finding) {
12945
- return {
12946
- threat_id: finding.threatId,
12947
- title: finding.title,
12948
- severity: finding.severity,
12949
- confidence: finding.confidence,
12950
- artifact: finding.artifact,
12951
- source_file: finding.sourceFile
12952
- };
14735
+ const { action: _, ...rest } = toFindingData(finding);
14736
+ return rest;
14737
+ }
14738
+ async function runSessionStartScan(context) {
14739
+ const logger = context.logger ?? nullLogger;
14740
+ const threats = await loadThreats(context.threatsDir, logger);
14741
+ const trustedDomains = await loadTrustedDomains(context.allowlistsDir, logger);
14742
+ if (threats.length === 0) {
14743
+ return [];
14744
+ }
14745
+ const plugins = context.plugins;
14746
+ if (plugins.length === 0) {
14747
+ return [];
14748
+ }
14749
+ const configHash = await computeConfigHash(context.sageVersion ?? "", context.threatsDir, context.allowlistsDir);
14750
+ const cache = await loadScanCache(configHash, context.scanCachePath, logger);
14751
+ const resultsWithFindings = [];
14752
+ let cacheModified = false;
14753
+ for (const plugin of plugins) {
14754
+ const cached = getCached(cache, plugin.key, plugin.version, plugin.lastUpdated);
14755
+ if (cached && cached.findings.length === 0) {
14756
+ continue;
14757
+ }
14758
+ if (cached && cached.findings.length > 0) {
14759
+ resultsWithFindings.push({
14760
+ plugin,
14761
+ findings: cached.findings.map(fromCachedFinding)
14762
+ });
14763
+ continue;
14764
+ }
14765
+ const result = await scanPlugin(plugin, threats, {
14766
+ checkUrls: context.checkUrls ?? true,
14767
+ checkFileHashes: context.checkFileHashes ?? true,
14768
+ trustedDomains,
14769
+ logger
14770
+ });
14771
+ storeResult(cache, plugin.key, plugin.version, plugin.lastUpdated, result.findings.map(toFindingData));
14772
+ cacheModified = true;
14773
+ if (result.findings.length > 0) {
14774
+ resultsWithFindings.push(result);
14775
+ }
14776
+ }
14777
+ if (cacheModified) {
14778
+ await saveScanCache(cache, context.scanCachePath, logger);
14779
+ }
14780
+ try {
14781
+ const sageConfig = await loadConfig(context.configPath, logger);
14782
+ for (const result of resultsWithFindings) {
14783
+ await logPluginScan(sageConfig.logging, result.plugin.key, result.plugin.version, result.findings.map(toAuditFindingData));
14784
+ }
14785
+ } catch {
14786
+ }
14787
+ return resultsWithFindings;
12953
14788
  }
12954
14789
 
12955
14790
  // ../core/dist/version-check.js
12956
- var GITHUB_RAW_URL = "https://raw.githubusercontent.com/avast/sage/main/packages/core/package.json";
14791
+ var import_node_os5 = require("node:os");
12957
14792
  var DEFAULT_TIMEOUT_MS = 5e3;
12958
14793
  function isNewerVersion(current, latest) {
12959
14794
  const parse = (v) => v.replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
@@ -12969,19 +14804,29 @@ function isNewerVersion(current, latest) {
12969
14804
  }
12970
14805
  return false;
12971
14806
  }
12972
- async function checkForUpdate(currentVersion, logger = nullLogger, timeoutMs = DEFAULT_TIMEOUT_MS) {
14807
+ async function checkForUpdate(currentVersion, logger = nullLogger, timeoutMs = DEFAULT_TIMEOUT_MS, context) {
12973
14808
  if (currentVersion === "dev") {
12974
14809
  logger.debug("Skipping version check for dev build");
12975
14810
  return null;
12976
14811
  }
12977
14812
  try {
12978
- const controller = new AbortController();
12979
- const timer = setTimeout(() => controller.abort(), timeoutMs);
12980
- const response = await fetch(GITHUB_RAW_URL, {
12981
- signal: controller.signal,
12982
- headers: { Accept: "application/json" }
14813
+ const response = await fetch(resolveEndpoint("/v1/version-check"), {
14814
+ method: "POST",
14815
+ signal: AbortSignal.timeout(timeoutMs),
14816
+ headers: {
14817
+ Accept: "application/json",
14818
+ "Content-Type": "application/json"
14819
+ },
14820
+ body: JSON.stringify({
14821
+ sage_version: currentVersion,
14822
+ agent_runtime: context?.agentRuntime,
14823
+ agent_runtime_version: context?.agentRuntimeVersion,
14824
+ os: process.platform,
14825
+ os_version: (0, import_node_os5.release)(),
14826
+ arch: process.arch,
14827
+ iid: context?.iid
14828
+ })
12983
14829
  });
12984
- clearTimeout(timer);
12985
14830
  if (!response.ok) {
12986
14831
  logger.debug(`Version check HTTP ${response.status}`);
12987
14832
  return null;
@@ -13003,61 +14848,147 @@ async function checkForUpdate(currentVersion, logger = nullLogger, timeoutMs = D
13003
14848
  }
13004
14849
  }
13005
14850
 
13006
- // src/approval-store.ts
13007
- var DEFAULT_PATH = (0, import_node_path9.join)((0, import_node_os5.homedir)(), ".sage", "approvals.json");
13008
- var DEFAULT_TTL_SECONDS = 300;
13009
- var ApprovalStore = class {
13010
- data = {};
13011
- path;
13012
- logger;
13013
- constructor(logger, path = DEFAULT_PATH) {
13014
- this.logger = logger;
13015
- this.path = path;
13016
- }
13017
- async load() {
14851
+ // ../core/dist/session-start.js
14852
+ async function runSessionStart(ctx) {
14853
+ const logger = ctx.logger ?? nullLogger;
14854
+ const sageDirPath = resolvePath(ctx.sageDirPath ?? "~/.sage");
14855
+ pruneOrphanedTmpFiles(sageDirPath).catch(() => {
14856
+ });
14857
+ const iidPromise = getInstallationId(sageDirPath).catch(() => void 0);
14858
+ const [scanResults, versionCheck] = await Promise.all([
14859
+ runSessionStartScan({
14860
+ plugins: ctx.plugins,
14861
+ threatsDir: ctx.threatsDir,
14862
+ allowlistsDir: ctx.allowlistsDir,
14863
+ sageVersion: ctx.version,
14864
+ logger,
14865
+ configPath: ctx.configPath,
14866
+ scanCachePath: ctx.scanCachePath,
14867
+ checkUrls: ctx.checkUrls,
14868
+ checkFileHashes: ctx.checkFileHashes
14869
+ }),
14870
+ iidPromise.then((iid) => checkForUpdate(ctx.version, logger, void 0, {
14871
+ agentRuntime: ctx.agentRuntime,
14872
+ agentRuntimeVersion: ctx.agentRuntimeVersion,
14873
+ iid
14874
+ })).catch(() => null)
14875
+ ]);
14876
+ return { scanResults, versionCheck };
14877
+ }
14878
+
14879
+ // ../core/dist/scan-handler.js
14880
+ async function runPluginScan(logger, context, plugins, threatsDir, allowlistsDir, version, agentRuntime) {
14881
+ logger.info(`Sage plugin scan started (${context})`, { threatsDir, allowlistsDir });
14882
+ const result = await runSessionStart({
14883
+ plugins,
14884
+ threatsDir,
14885
+ allowlistsDir,
14886
+ version,
14887
+ logger,
14888
+ agentRuntime
14889
+ });
14890
+ logger.info(`Sage plugin scan (${context}) complete`, {
14891
+ findings: result.scanResults.length,
14892
+ updateAvailable: result.versionCheck?.updateAvailable ?? false
14893
+ });
14894
+ return formatSessionStartMessage(version, result);
14895
+ }
14896
+ function createScanHandler(options) {
14897
+ const { logger, context, discoverPlugins: discoverPlugins2, selfPrefix, threatsDir, allowlistsDir, version, agentRuntime, onResult } = options;
14898
+ return async () => {
13018
14899
  try {
13019
- const raw = await getFileContent(this.path);
13020
- const parsed = JSON.parse(raw);
13021
- const now = Date.now();
13022
- this.data = {};
13023
- for (const [id, entry] of Object.entries(parsed)) {
13024
- if (entry && new Date(entry.expiresAt).getTime() > now) {
13025
- this.data[id] = entry;
13026
- }
14900
+ let plugins = await discoverPlugins2();
14901
+ plugins = plugins.filter((p) => !p.key.startsWith(selfPrefix));
14902
+ if (plugins.length === 0) {
14903
+ logger.warn(`Sage plugin scan (${context}): no plugins to scan after filtering`);
13027
14904
  }
13028
- } catch {
13029
- this.data = {};
14905
+ const msg = await runPluginScan(logger, context, plugins, threatsDir, allowlistsDir, version, agentRuntime);
14906
+ onResult?.(msg);
14907
+ } catch (e) {
14908
+ logger.error(`Sage ${context} scan failed`, { error: String(e) });
13030
14909
  }
13031
- }
13032
- isApproved(actionId) {
13033
- const entry = this.data[actionId];
13034
- if (!entry) return false;
13035
- if (new Date(entry.expiresAt).getTime() <= Date.now()) {
13036
- delete this.data[actionId];
13037
- return false;
14910
+ };
14911
+ }
14912
+
14913
+ // src/allowlist-tools.ts
14914
+ function createAllowlistAddTool(approvalStore, logger) {
14915
+ return {
14916
+ name: "sage_allowlist_add",
14917
+ description: "Permanently allow a URL, command, or file path after recent user approval through Sage.",
14918
+ parameters: {
14919
+ type: "object",
14920
+ properties: {
14921
+ type: {
14922
+ type: "string",
14923
+ enum: ["url", "command", "file_path"],
14924
+ description: "Artifact type"
14925
+ },
14926
+ value: {
14927
+ type: "string",
14928
+ description: "Exact URL, command, or file path to allowlist"
14929
+ },
14930
+ reason: {
14931
+ type: "string",
14932
+ description: "Optional reason for allowlist entry"
14933
+ }
14934
+ },
14935
+ required: ["type", "value"]
14936
+ },
14937
+ async execute(_toolCallId, params) {
14938
+ const { type, value, reason } = params;
14939
+ const msg = await addToAllowlist(approvalStore, type, value, reason, void 0, logger);
14940
+ return { content: [{ type: "text", text: msg }] };
13038
14941
  }
13039
- return true;
13040
- }
13041
- async approve(actionId, ttlSeconds = DEFAULT_TTL_SECONDS) {
13042
- const now = /* @__PURE__ */ new Date();
13043
- this.data[actionId] = {
13044
- approvedAt: now.toISOString(),
13045
- expiresAt: new Date(now.getTime() + ttlSeconds * 1e3).toISOString()
13046
- };
13047
- await this.save();
13048
- }
13049
- async save() {
13050
- try {
13051
- await atomicWriteJson(this.path, this.data);
13052
- } catch (e) {
13053
- this.logger.warn(`Failed to save approvals to ${this.path}`, { error: String(e) });
14942
+ };
14943
+ }
14944
+ function createAllowlistRemoveTool(logger) {
14945
+ return {
14946
+ name: "sage_allowlist_remove",
14947
+ description: "Remove a URL, command, or file path from the Sage allowlist.",
14948
+ parameters: {
14949
+ type: "object",
14950
+ properties: {
14951
+ type: {
14952
+ type: "string",
14953
+ enum: ["url", "command", "file_path"],
14954
+ description: "Artifact type"
14955
+ },
14956
+ value: {
14957
+ type: "string",
14958
+ description: "URL/file path, or command text/command hash for command entries"
14959
+ }
14960
+ },
14961
+ required: ["type", "value"]
14962
+ },
14963
+ async execute(_toolCallId, params) {
14964
+ const { type, value } = params;
14965
+ const msg = await removeFromAllowlist(type, value, void 0, logger);
14966
+ return { content: [{ type: "text", text: msg }] };
13054
14967
  }
14968
+ };
14969
+ }
14970
+
14971
+ // src/bundled-dirs.ts
14972
+ var import_node_fs = require("node:fs");
14973
+ var import_node_path10 = require("node:path");
14974
+ var packageRoot = (0, import_node_path10.resolve)(__dirname, "..");
14975
+ function getBundledDataDirs() {
14976
+ return {
14977
+ threatsDir: (0, import_node_path10.join)(packageRoot, "resources", "threats"),
14978
+ allowlistsDir: (0, import_node_path10.join)(packageRoot, "resources", "allowlists")
14979
+ };
14980
+ }
14981
+ var cachedVersion;
14982
+ function getSageVersion() {
14983
+ if (cachedVersion) return cachedVersion;
14984
+ try {
14985
+ const pkg = JSON.parse((0, import_node_fs.readFileSync)((0, import_node_path10.join)(packageRoot, "package.json"), "utf-8"));
14986
+ cachedVersion = pkg.version ?? "0.0.0";
14987
+ } catch {
14988
+ cachedVersion = "0.0.0";
13055
14989
  }
13056
- static actionId(toolName, params) {
13057
- const payload = JSON.stringify({ toolName, params });
13058
- return (0, import_node_crypto5.createHash)("sha256").update(payload).digest("hex");
13059
- }
13060
- };
14990
+ return cachedVersion;
14991
+ }
13061
14992
 
13062
14993
  // src/gate-tool.ts
13063
14994
  function createSageApproveTool(approvalStore) {
@@ -13079,14 +15010,15 @@ function createSageApproveTool(approvalStore) {
13079
15010
  required: ["actionId", "approved"]
13080
15011
  },
13081
15012
  async execute(_toolCallId, params) {
13082
- if (params.approved) {
13083
- await approvalStore.approve(params.actionId);
15013
+ if (!params.approved) {
15014
+ approvalStore.deletePending(params.actionId);
13084
15015
  return {
13085
- content: [{ type: "text", text: "Approved. Retry the tool call." }]
15016
+ content: [{ type: "text", text: "Rejected by user." }]
13086
15017
  };
13087
15018
  }
15019
+ const msg = await approveAction(approvalStore, params.actionId);
13088
15020
  return {
13089
- content: [{ type: "text", text: "Rejected by user." }]
15021
+ content: [{ type: "text", text: msg }]
13090
15022
  };
13091
15023
  }
13092
15024
  };
@@ -13113,30 +15045,8 @@ function createLogger(pluginLogger) {
13113
15045
  };
13114
15046
  }
13115
15047
 
13116
- // src/bundled-dirs.ts
13117
- var import_node_fs = require("node:fs");
13118
- var import_node_path10 = require("node:path");
13119
- var packageRoot = (0, import_node_path10.resolve)(__dirname, "..");
13120
- function getBundledDataDirs() {
13121
- return {
13122
- threatsDir: (0, import_node_path10.join)(packageRoot, "resources", "threats"),
13123
- allowlistsDir: (0, import_node_path10.join)(packageRoot, "resources", "allowlists")
13124
- };
13125
- }
13126
- var cachedVersion;
13127
- function getSageVersion() {
13128
- if (cachedVersion) return cachedVersion;
13129
- try {
13130
- const pkg = JSON.parse((0, import_node_fs.readFileSync)((0, import_node_path10.join)(packageRoot, "package.json"), "utf-8"));
13131
- cachedVersion = pkg.version ?? "0.0.0";
13132
- } catch {
13133
- cachedVersion = "0.0.0";
13134
- }
13135
- return cachedVersion;
13136
- }
13137
-
13138
15048
  // src/plugin-discovery.ts
13139
- var import_promises6 = require("node:fs/promises");
15049
+ var import_promises7 = require("node:fs/promises");
13140
15050
  var import_node_os6 = require("node:os");
13141
15051
  var import_node_path11 = require("node:path");
13142
15052
  var DEFAULT_EXTENSIONS_DIR = (0, import_node_path11.join)((0, import_node_os6.homedir)(), ".openclaw", "extensions");
@@ -13144,7 +15054,7 @@ async function discoverOpenClawPlugins(logger, extensionsDir = DEFAULT_EXTENSION
13144
15054
  logger.info("Sage plugin discovery: scanning extensions directory", { path: extensionsDir });
13145
15055
  let entries;
13146
15056
  try {
13147
- entries = await (0, import_promises6.readdir)(extensionsDir);
15057
+ entries = await (0, import_promises7.readdir)(extensionsDir);
13148
15058
  } catch {
13149
15059
  logger.debug("OpenClaw extensions directory not found", { path: extensionsDir });
13150
15060
  return [];
@@ -13154,7 +15064,7 @@ async function discoverOpenClawPlugins(logger, extensionsDir = DEFAULT_EXTENSION
13154
15064
  const extDir = (0, import_node_path11.join)(extensionsDir, entry);
13155
15065
  let stats;
13156
15066
  try {
13157
- stats = await (0, import_promises6.stat)(extDir);
15067
+ stats = await (0, import_promises7.stat)(extDir);
13158
15068
  if (!stats.isDirectory()) continue;
13159
15069
  } catch {
13160
15070
  continue;
@@ -13184,110 +15094,26 @@ async function discoverOpenClawPlugins(logger, extensionsDir = DEFAULT_EXTENSION
13184
15094
  }
13185
15095
 
13186
15096
  // src/startup-scan.ts
13187
- var SAGE_PLUGIN_ID = "sage-openclaw";
13188
- async function runScan(logger, context) {
15097
+ function createOpenClawScanHandler(logger, context, onResult) {
13189
15098
  const { threatsDir, allowlistsDir } = getBundledDataDirs();
13190
15099
  const version = getSageVersion();
13191
- logger.info(`Sage plugin scan started (${context})`, { threatsDir, allowlistsDir });
13192
- const [threats, trustedDomains, versionCheck] = await Promise.all([
13193
- loadThreats(threatsDir, logger),
13194
- loadTrustedDomains(allowlistsDir, logger),
13195
- checkForUpdate(version, logger)
13196
- ]);
13197
- const updateNotice = versionCheck?.updateAvailable ? formatUpdateNotice(versionCheck) : null;
13198
- if (threats.length === 0) {
13199
- logger.warn(`Sage plugin scan (${context}): no threats loaded, skipping`);
13200
- return updateNotice;
13201
- }
13202
- logger.info(`Sage plugin scan (${context}): loaded ${threats.length} threat definitions`);
13203
- let plugins = await discoverOpenClawPlugins(logger);
13204
- plugins = plugins.filter((p) => !p.key.startsWith(`${SAGE_PLUGIN_ID}@`));
13205
- if (plugins.length === 0) {
13206
- logger.warn(`Sage plugin scan (${context}): no plugins to scan after filtering`);
13207
- return updateNotice;
13208
- }
13209
- logger.info(`Sage plugin scan (${context}): ${plugins.length} plugin(s) to scan`, {
13210
- keys: plugins.map((p) => p.key)
15100
+ return createScanHandler({
15101
+ logger,
15102
+ context,
15103
+ discoverPlugins: () => discoverOpenClawPlugins(logger),
15104
+ selfPrefix: "sage-openclaw@",
15105
+ threatsDir,
15106
+ allowlistsDir,
15107
+ version,
15108
+ agentRuntime: "openclaw",
15109
+ onResult
13211
15110
  });
13212
- const configHash = await computeConfigHash("", threatsDir, allowlistsDir);
13213
- const cache = await loadScanCache(configHash, void 0, logger);
13214
- const resultsWithFindings = [];
13215
- let cacheModified = false;
13216
- let scannedCount = 0;
13217
- for (const plugin of plugins) {
13218
- const cached = getCached(cache, plugin.key, plugin.version, plugin.lastUpdated);
13219
- if (cached && cached.findings.length === 0) {
13220
- logger.info(`Sage plugin scan (${context}): cache hit (clean) for ${plugin.key}`);
13221
- continue;
13222
- }
13223
- if (cached && cached.findings.length > 0) {
13224
- logger.info(
13225
- `Sage plugin scan (${context}): cache hit with ${cached.findings.length} finding(s) for ${plugin.key}`
13226
- );
13227
- resultsWithFindings.push({ plugin, findings: cached.findings.map(fromCachedFinding) });
13228
- continue;
13229
- }
13230
- logger.info(`Sage plugin scan (${context}): scanning ${plugin.key}`);
13231
- const result = await scanPlugin(plugin, threats, {
13232
- checkUrls: true,
13233
- trustedDomains,
13234
- logger
13235
- });
13236
- scannedCount++;
13237
- storeResult(
13238
- cache,
13239
- plugin.key,
13240
- plugin.version,
13241
- plugin.lastUpdated,
13242
- result.findings.map(toFindingData)
13243
- );
13244
- cacheModified = true;
13245
- if (result.findings.length > 0) {
13246
- resultsWithFindings.push(result);
13247
- }
13248
- }
13249
- if (cacheModified) {
13250
- await saveScanCache(cache, void 0, logger);
13251
- }
13252
- logger.info(
13253
- `Sage plugin scan (${context}) complete: ${scannedCount} scanned, ${resultsWithFindings.length} with findings, cache ${cacheModified ? "updated" : "unchanged"}`
13254
- );
13255
- try {
13256
- const sageConfig = await loadConfig(void 0, logger);
13257
- for (const result of resultsWithFindings) {
13258
- await logPluginScan(
13259
- sageConfig.logging,
13260
- result.plugin.key,
13261
- result.plugin.version,
13262
- result.findings.map(toAuditFindingData)
13263
- );
13264
- }
13265
- } catch {
13266
- }
13267
- if (resultsWithFindings.length > 0) {
13268
- const banner = formatThreatBanner(version, resultsWithFindings, versionCheck);
13269
- logger.warn(`Sage: threat findings detected`, {
13270
- plugins: resultsWithFindings.map((r) => r.plugin.key)
13271
- });
13272
- return banner;
13273
- }
13274
- return updateNotice;
13275
- }
13276
- function createScanHandler(logger, context, onFindings) {
13277
- return async () => {
13278
- try {
13279
- const findings = await runScan(logger, context);
13280
- onFindings?.(findings);
13281
- } catch (e) {
13282
- logger.error(`Sage ${context} scan failed`, { error: String(e) });
13283
- }
13284
- };
13285
15111
  }
13286
- function createStartupScanHandler(logger, onFindings) {
13287
- return createScanHandler(logger, "startup", onFindings);
15112
+ function createStartupScanHandler(logger, onResult) {
15113
+ return createOpenClawScanHandler(logger, "startup", onResult);
13288
15114
  }
13289
- function createSessionScanHandler(logger, onFindings) {
13290
- return createScanHandler(logger, "session", onFindings);
15115
+ function createSessionScanHandler(logger, onResult) {
15116
+ return createOpenClawScanHandler(logger, "session", onResult);
13291
15117
  }
13292
15118
  function createBeforeAgentStartHandler(getFindings, clearFindings, logger) {
13293
15119
  return () => {
@@ -13341,153 +15167,32 @@ function mapToolToArtifacts(toolName, params) {
13341
15167
  return null;
13342
15168
  }
13343
15169
  }
13344
- function createToolCallHandler(approvalStore, logger, sessionKey = "unknown") {
13345
- return async (event) => {
15170
+ function createToolCallHandler(approvalStore, logger, threatsDir, allowlistsDir) {
15171
+ async function handleToolCall(event, ctx) {
13346
15172
  try {
13347
15173
  const { toolName, params } = event;
13348
15174
  const artifacts = mapToolToArtifacts(toolName, params);
13349
15175
  if (!artifacts || artifacts.length === 0) return void 0;
13350
- const actionId = ApprovalStore.actionId(toolName, params);
13351
- if (approvalStore.isApproved(actionId)) return void 0;
13352
- const config = await loadConfig(void 0, logger).catch(
13353
- () => loadConfig("/dev/null", logger)
15176
+ const sessionId = ctx?.sessionKey ?? "unknown";
15177
+ const { verdict, actionId } = await guardToolCall(
15178
+ { sessionId, toolName, toolInput: params, artifacts },
15179
+ { threatsDir, allowlistsDir, logger },
15180
+ approvalStore
13354
15181
  );
13355
- try {
13356
- const allowlist = await loadAllowlist(config.allowlist, logger);
13357
- if (isAllowlisted(allowlist, artifacts)) {
13358
- await logVerdict(
13359
- config.logging,
13360
- sessionKey,
13361
- toolName,
13362
- params,
13363
- {
13364
- decision: "allow",
13365
- category: "none",
13366
- confidence: 1,
13367
- severity: "info",
13368
- source: "allowlisted",
13369
- artifacts: [],
13370
- matchedThreatId: null,
13371
- reasons: []
13372
- },
13373
- true
13374
- );
13375
- return void 0;
13376
- }
13377
- } catch {
13378
- }
13379
- let cache = null;
13380
- try {
13381
- cache = new VerdictCache(config.cache, logger);
13382
- await cache.load();
13383
- } catch {
13384
- cache = null;
13385
- }
13386
- const urls = artifacts.filter((a) => a.type === "url").map((a) => a.value);
13387
- const cachedUrlVerdicts = /* @__PURE__ */ new Map();
13388
- let uncachedUrls = [];
13389
- if (cache && urls.length > 0) {
13390
- try {
13391
- for (const url of urls) {
13392
- const cached = cache.getUrl(url);
13393
- if (cached) {
13394
- cachedUrlVerdicts.set(url, cached);
13395
- } else {
13396
- uncachedUrls.push(url);
13397
- }
13398
- }
13399
- } catch {
13400
- uncachedUrls = urls;
13401
- }
13402
- }
13403
- const { threatsDir, allowlistsDir } = getBundledDataDirs();
13404
- let heuristicMatches = [];
13405
- if (config.heuristics_enabled) {
13406
- const threats = await loadThreats(threatsDir, logger);
13407
- const trustedDomains = await loadTrustedDomains(allowlistsDir, logger);
13408
- const heuristics = new HeuristicsEngine(threats, trustedDomains);
13409
- heuristicMatches = heuristics.match(artifacts);
13410
- }
13411
- let urlCheckResults = [];
13412
- const urlsToCheck = cache ? uncachedUrls : urls;
13413
- if (urlsToCheck.length > 0 && config.url_check.enabled) {
13414
- try {
13415
- const client = new UrlCheckClient(config.url_check, logger);
13416
- urlCheckResults = await client.checkUrls(urlsToCheck);
13417
- } catch {
13418
- }
13419
- }
13420
- const engine = new DecisionEngine(config.sensitivity);
13421
- let verdict = await engine.decide(heuristicMatches, urlCheckResults);
13422
- if (cachedUrlVerdicts.size > 0 && verdict.decision === "allow") {
13423
- for (const [url, cv] of cachedUrlVerdicts) {
13424
- if (cv.verdict !== "allow") {
13425
- verdict = {
13426
- decision: cv.verdict,
13427
- category: "network_egress",
13428
- confidence: 1,
13429
- severity: cv.severity,
13430
- source: `cache(${cv.source})`,
13431
- artifacts: [url],
13432
- matchedThreatId: null,
13433
- reasons: cv.reasons
13434
- };
13435
- break;
13436
- }
13437
- }
13438
- }
13439
- if (cache) {
13440
- try {
13441
- for (const result of urlCheckResults) {
13442
- let cv;
13443
- if (result.isMalicious) {
13444
- cv = {
13445
- verdict: "deny",
13446
- severity: "critical",
13447
- reasons: [
13448
- `URL check: malicious (${result.findings.map((f) => `${f.severityName}/${f.typeName}`).join(", ")})`
13449
- ],
13450
- source: "url_check"
13451
- };
13452
- } else if (result.flags.length > 0) {
13453
- cv = {
13454
- verdict: "ask",
13455
- severity: "warning",
13456
- reasons: [`URL check: suspicious (${result.flags.join(", ")})`],
13457
- source: "url_check"
13458
- };
13459
- } else {
13460
- cv = {
13461
- verdict: "allow",
13462
- severity: "info",
13463
- reasons: [],
13464
- source: "url_check"
13465
- };
13466
- }
13467
- cache.putUrl(result.url, cv, result.isMalicious);
13468
- }
13469
- await cache.save();
13470
- } catch {
13471
- }
13472
- }
13473
- try {
13474
- await logVerdict(config.logging, sessionKey, toolName, params, verdict);
13475
- } catch {
13476
- }
13477
15182
  if (verdict.decision === "allow") return void 0;
13478
- const reasonText = verdict.reasons.length > 0 ? verdict.reasons.slice(0, 5).join("; ") : verdict.category;
13479
15183
  if (verdict.decision === "deny") {
13480
- return { block: true, blockReason: `Sage blocked: ${reasonText}` };
15184
+ return { block: true, blockReason: formatDenyMessage(verdict) };
13481
15185
  }
13482
15186
  return {
13483
15187
  block: true,
13484
- blockReason: `Sage flagged: ${reasonText}. Ask the user whether to proceed \u2014 do NOT auto-approve. If the user confirms, call sage_approve({actionId: "${actionId}", approved: true}). If the user declines, call sage_approve({actionId: "${actionId}", approved: false}).`
15188
+ blockReason: formatAskMessage(actionId, verdict, artifacts)
13485
15189
  };
13486
15190
  } catch (e) {
13487
15191
  logger.error("tool-handler error, failing open", { error: String(e) });
13488
15192
  return void 0;
13489
15193
  }
13490
- };
15194
+ }
15195
+ return handleToolCall;
13491
15196
  }
13492
15197
 
13493
15198
  // src/index.ts
@@ -13500,15 +15205,25 @@ var index_default = {
13500
15205
  },
13501
15206
  register(api) {
13502
15207
  const logger = createLogger(api.logger);
13503
- const approvalStore = new ApprovalStore(logger);
15208
+ const approvalStore = new ApprovalStore();
15209
+ const { threatsDir, allowlistsDir } = getBundledDataDirs();
15210
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1e3;
15211
+ const interval = setInterval(() => approvalStore.cleanup(), CLEANUP_INTERVAL_MS);
15212
+ if (typeof interval.unref === "function") interval.unref();
13504
15213
  let pendingFindings = null;
13505
15214
  const onFindings = (msg) => {
13506
- if (msg) pendingFindings = msg;
15215
+ pendingFindings = msg;
13507
15216
  };
13508
- api.on("before_tool_call", createToolCallHandler(approvalStore, logger), {
13509
- priority: 100
13510
- });
15217
+ api.on(
15218
+ "before_tool_call",
15219
+ createToolCallHandler(approvalStore, logger, threatsDir, allowlistsDir),
15220
+ { priority: 100 }
15221
+ );
13511
15222
  api.registerTool(createSageApproveTool(approvalStore));
15223
+ api.registerTool(
15224
+ createAllowlistAddTool(approvalStore, logger)
15225
+ );
15226
+ api.registerTool(createAllowlistRemoveTool(logger));
13512
15227
  api.on("gateway_start", createStartupScanHandler(logger, onFindings));
13513
15228
  api.on("session_start", createSessionScanHandler(logger, onFindings));
13514
15229
  api.on(