@gendigital/sage-openclaw 0.4.4 → 0.5.3

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");
@@ -11487,10 +11482,14 @@ var PackageCheckConfigSchema = external_exports.object({
11487
11482
  // v1: all scoped packages (@scope/pkg) are skipped automatically.
11488
11483
  // Future: add private_scopes / public_scopes config for fine-grained control.
11489
11484
  });
11485
+ var AmsiCheckConfigSchema = external_exports.object({
11486
+ enabled: external_exports.boolean().default(true)
11487
+ });
11490
11488
  var ConfigSchema = external_exports.object({
11491
11489
  url_check: UrlCheckConfigSchema.default({}),
11492
11490
  file_check: FileCheckConfigSchema.default({}),
11493
11491
  package_check: PackageCheckConfigSchema.default({}),
11492
+ amsi_check: AmsiCheckConfigSchema.default({}),
11494
11493
  heuristics_enabled: external_exports.boolean().default(true),
11495
11494
  cache: CacheConfigSchema.default({}),
11496
11495
  allowlist: AllowlistConfigSchema.default({}),
@@ -11500,37 +11499,112 @@ var ConfigSchema = external_exports.object({
11500
11499
  });
11501
11500
 
11502
11501
  // ../core/dist/config.js
11503
- 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
+ }
11504
11522
  function resolvePath(pathStr) {
11505
11523
  if (pathStr.startsWith("~/") || pathStr === "~") {
11506
- 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));
11507
11525
  }
11508
11526
  return pathStr;
11509
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
+ }
11510
11584
  async function loadConfig(configPath, logger = nullLogger) {
11511
- const path = configPath ?? DEFAULT_CONFIG_PATH;
11585
+ const path = configPath ?? defaultConfigPath();
11512
11586
  let raw;
11513
11587
  try {
11514
11588
  raw = await getFileContent(path);
11515
11589
  } catch {
11516
- return ConfigSchema.parse({});
11590
+ return sanitizeConfigPaths(ConfigSchema.parse({}), logger);
11517
11591
  }
11518
11592
  let data;
11519
11593
  try {
11520
11594
  data = JSON.parse(raw);
11521
11595
  } catch (e) {
11522
11596
  logger.warn(`Failed to parse config from ${path}`, { error: String(e) });
11523
- return ConfigSchema.parse({});
11597
+ return sanitizeConfigPaths(ConfigSchema.parse({}), logger);
11524
11598
  }
11525
11599
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
11526
11600
  logger.warn(`Config file ${path} does not contain a JSON object`);
11527
- return ConfigSchema.parse({});
11601
+ return sanitizeConfigPaths(ConfigSchema.parse({}), logger);
11528
11602
  }
11529
11603
  try {
11530
- return ConfigSchema.parse(data);
11604
+ return sanitizeConfigPaths(ConfigSchema.parse(data), logger);
11531
11605
  } catch (e) {
11532
11606
  logger.warn(`Config validation failed, using defaults`, { error: String(e) });
11533
- return ConfigSchema.parse({});
11607
+ return sanitizeConfigPaths(ConfigSchema.parse({}), logger);
11534
11608
  }
11535
11609
  }
11536
11610
 
@@ -11560,6 +11634,16 @@ function normalizeFilePath(raw) {
11560
11634
  function emptyAllowlist() {
11561
11635
  return { urls: {}, commands: {}, filePaths: {} };
11562
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
+ }
11563
11647
  function parseEntries(raw) {
11564
11648
  const entries = {};
11565
11649
  for (const [key, entryData] of Object.entries(raw)) {
@@ -11612,6 +11696,29 @@ async function loadAllowlist(config, logger = nullLogger) {
11612
11696
  filePaths
11613
11697
  };
11614
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
+ }
11615
11722
  function isAllowlisted(allowlist, artifacts) {
11616
11723
  for (const artifact of artifacts) {
11617
11724
  if (artifact.type !== "command") {
@@ -11635,6 +11742,115 @@ function isAllowlisted(allowlist, artifacts) {
11635
11742
  }
11636
11743
  return false;
11637
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
+ };
11638
11854
 
11639
11855
  // ../core/dist/audit-log.js
11640
11856
  var import_promises = require("node:fs/promises");
@@ -11647,7 +11863,7 @@ function toolInputSummary(toolName, toolInput) {
11647
11863
  if (toolName === "WebFetch") {
11648
11864
  return String(toolInput.url ?? "").slice(0, MAX_SUMMARY_LEN);
11649
11865
  }
11650
- if (toolName === "Write" || toolName === "Edit") {
11866
+ if (toolName === "Write" || toolName === "Edit" || toolName === "Read" || toolName === "Delete") {
11651
11867
  return String(toolInput.file_path ?? "").slice(0, MAX_SUMMARY_LEN);
11652
11868
  }
11653
11869
  return JSON.stringify(toolInput).slice(0, MAX_SUMMARY_LEN);
@@ -11864,8 +12080,483 @@ var VerdictCache = class {
11864
12080
  }
11865
12081
  };
11866
12082
 
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;
12093
+ }
12094
+ function isAmsiSupported() {
12095
+ return process.platform === "win32" || isWSL();
12096
+ }
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 {
12107
+ logger;
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) {
12118
+ this.logger = logger;
12119
+ }
12120
+ get isAvailable() {
12121
+ return this.available;
12122
+ }
12123
+ async init() {
12124
+ try {
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);
12149
+ }
12150
+ } catch {
12151
+ }
12152
+ this.context = null;
12153
+ return;
12154
+ }
12155
+ this.session = sessOut[0];
12156
+ this.available = true;
12157
+ this.logger.debug("AMSI: koffi backend initialized");
12158
+ } catch (e) {
12159
+ this.logger.debug("AMSI: koffi backend init failed", { error: String(e) });
12160
+ this.available = false;
12161
+ }
12162
+ }
12163
+ async scanString(content, contentName) {
12164
+ if (!this.available || !this.fnScanBuffer || !this.context || !this.session) {
12165
+ return null;
12166
+ }
12167
+ try {
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 });
12174
+ return null;
12175
+ }
12176
+ const amsiResult = resultOut[0] ?? 0;
12177
+ this.logger.debug("AMSI: koffi scan result", { contentName, amsiResult });
12178
+ return interpretAmsiResult(amsiResult, content, contentName);
12179
+ } catch (e) {
12180
+ this.logger.warn("AMSI: koffi scanBuffer failed", { error: String(e), contentName });
12181
+ return null;
12182
+ }
12183
+ }
12184
+ close() {
12185
+ try {
12186
+ if (this.session && this.context && this.fnCloseSession) {
12187
+ this.fnCloseSession(this.context, this.session);
12188
+ }
12189
+ } catch {
12190
+ }
12191
+ try {
12192
+ if (this.context && this.fnUninitialize) {
12193
+ this.fnUninitialize(this.context);
12194
+ }
12195
+ } catch {
12196
+ }
12197
+ this.session = null;
12198
+ this.context = null;
12199
+ this.available = false;
12200
+ }
12201
+ };
12202
+ var AMSI_CSHARP_TYPE = `
12203
+ using System;
12204
+ using System.Runtime.InteropServices;
12205
+ using System.Text;
12206
+
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
+
11867
12558
  // ../core/dist/version.js
11868
- var VERSION = true ? "0.4.4" : "dev";
12559
+ var VERSION = true ? "0.5.2" : "dev";
11869
12560
 
11870
12561
  // ../core/dist/clients/file-check.js
11871
12562
  var DEFAULT_TIMEOUT = 5;
@@ -12000,32 +12691,190 @@ var FileCheckClient = class {
12000
12691
  }
12001
12692
  };
12002
12693
 
12003
- // ../core/dist/clients/url-check.js
12694
+ // ../core/dist/clients/package-registry.js
12695
+ var NPM_REGISTRY = "https://registry.npmjs.org";
12696
+ var PYPI_REGISTRY = "https://pypi.org/pypi";
12004
12697
  var DEFAULT_TIMEOUT2 = 5;
12005
- var MAX_URLS_PER_REQUEST = 50;
12006
- var SERVICE_NAME2 = "sage";
12007
- function getProviderTld2() {
12008
- return "com";
12009
- }
12010
- var REQUEST_HEADERS2 = [
12011
- { name: "Accept", value: "application/json" },
12012
- { name: "Content-Type", value: "application/json" },
12013
- { name: "User-Agent", value: SERVICE_NAME2 }
12014
- ];
12015
- function getProviderName2() {
12016
- return "avast";
12017
- }
12018
- var UrlCheckClient = class {
12019
- endpoint;
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 {
12020
12701
  timeoutMs;
12021
12702
  logger;
12022
12703
  constructor(config, logger = nullLogger) {
12023
- this.endpoint = config?.endpoint ?? this.resolveEndpoint();
12024
12704
  this.timeoutMs = (config?.timeout_seconds ?? DEFAULT_TIMEOUT2) * 1e3;
12025
12705
  this.logger = logger;
12026
12706
  }
12027
- buildPath() {
12028
- return "/url-check";
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
+ ];
12857
+ function getProviderName2() {
12858
+ return "avast";
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
+ }
12870
+ var UrlCheckClient = class {
12871
+ endpoint;
12872
+ timeoutMs;
12873
+ logger;
12874
+ constructor(config, logger = nullLogger) {
12875
+ this.endpoint = config?.endpoint ?? resolveEndpoint("/url-check");
12876
+ this.timeoutMs = (config?.timeout_seconds ?? DEFAULT_TIMEOUT3) * 1e3;
12877
+ this.logger = logger;
12029
12878
  }
12030
12879
  async checkUrls(urls) {
12031
12880
  if (urls.length === 0)
@@ -12037,9 +12886,6 @@ var UrlCheckClient = class {
12037
12886
  const batchResults = await Promise.all(batches.map((batch) => this.checkBatch(batch)));
12038
12887
  return batchResults.flat();
12039
12888
  }
12040
- getSubdomain() {
12041
- return "svc";
12042
- }
12043
12889
  async checkBatch(urls) {
12044
12890
  const queries = urls.map((url) => ({ key: { "url-like": url } }));
12045
12891
  const payload = {
@@ -12075,9 +12921,6 @@ var UrlCheckClient = class {
12075
12921
  return [];
12076
12922
  }
12077
12923
  }
12078
- buildDomain() {
12079
- return [this.getSubdomain(), getProviderName2(), getProviderTld2()].join(".");
12080
- }
12081
12924
  parseAnswer(answer) {
12082
12925
  try {
12083
12926
  const url = answer.key ?? "";
@@ -12102,9 +12945,6 @@ var UrlCheckClient = class {
12102
12945
  return null;
12103
12946
  }
12104
12947
  }
12105
- resolveEndpoint() {
12106
- return `https://${SERVICE_NAME2}-proxy.${this.buildDomain()}${this.buildPath()}`;
12107
- }
12108
12948
  };
12109
12949
 
12110
12950
  // ../core/dist/engine.js
@@ -12135,17 +12975,8 @@ var DecisionEngine = class {
12135
12975
  constructor(sensitivity = "balanced") {
12136
12976
  this.threshold = SENSITIVITY_THRESHOLDS[sensitivity] ?? CONFIDENCE_THRESHOLD;
12137
12977
  }
12138
- async decide(heuristicMatchesOrSources, urlCheckResults) {
12139
- let sources;
12140
- if (Array.isArray(heuristicMatchesOrSources)) {
12141
- sources = {
12142
- heuristicMatches: heuristicMatchesOrSources,
12143
- urlCheckResults: urlCheckResults ?? []
12144
- };
12145
- } else {
12146
- sources = heuristicMatchesOrSources;
12147
- }
12148
- 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);
12149
12980
  if (signals.length === 0) {
12150
12981
  return this.allowVerdict();
12151
12982
  }
@@ -12172,7 +13003,7 @@ var DecisionEngine = class {
12172
13003
  reasons: allReasons
12173
13004
  };
12174
13005
  }
12175
- collectSignals(heuristicMatches, urlCheckResults, packageCheckResults) {
13006
+ collectSignals(heuristicMatches, urlCheckResults, packageCheckResults, amsiCheckResults) {
12176
13007
  const signals = [];
12177
13008
  for (const match of heuristicMatches) {
12178
13009
  signals.push({
@@ -12222,6 +13053,33 @@ var DecisionEngine = class {
12222
13053
  signals.push(signal);
12223
13054
  }
12224
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
+ }
12225
13083
  return signals;
12226
13084
  }
12227
13085
  packageVerdictToSignal(pkg) {
@@ -12511,6 +13369,459 @@ var HeuristicsEngine = class {
12511
13369
  }
12512
13370
  };
12513
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;
13642
+ continue;
13643
+ }
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("-"))
13675
+ continue;
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("-"))
13689
+ continue;
13690
+ if (!isPackageName(t))
13691
+ break;
13692
+ const { name, version } = splitNameVersion(t);
13693
+ packages.push({ name, version, registry: "npm", source: "command" });
13694
+ break;
13695
+ }
13696
+ return packages;
13697
+ }
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" });
13708
+ }
13709
+ return packages;
13710
+ }
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" });
13724
+ }
13725
+ return packages;
13726
+ }
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
+ });
13770
+ }
13771
+ }
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
+ });
13793
+ }
13794
+ } catch {
13795
+ }
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;
13805
+ }
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
+ });
13820
+ }
13821
+ }
13822
+ return packages;
13823
+ }
13824
+
12514
13825
  // ../core/dist/threat-loader.js
12515
13826
  var import_promises3 = require("node:fs/promises");
12516
13827
  var import_node_path6 = require("node:path");
@@ -12617,32 +13928,283 @@ async function loadThreats(threatDir, logger = nullLogger) {
12617
13928
  return threats;
12618
13929
  }
12619
13930
 
12620
- // ../core/dist/format.js
12621
- var PAD = 12;
12622
- var SEPARATOR_WIDTH = 48;
12623
- function severityEmoji(severity) {
12624
- const s = severity.toLowerCase();
12625
- if (s === "critical" || s === "high")
12626
- return "\u{1F6A8}";
12627
- if (s === "medium" || s === "warn" || s === "warning")
12628
- return "\u26A0\uFE0F";
12629
- return "\u2139\uFE0F";
12630
- }
12631
- function kv(key, value) {
12632
- return ` ${key.padEnd(PAD)}${value}`;
12633
- }
12634
- function separatorLine(headerLength) {
12635
- return "\u2501".repeat(headerLength);
12636
- }
12637
- function formatUpdateNotice(result) {
12638
- return `\u2B06\uFE0F Update available: v${result.currentVersion} \u2192 v${result.latestVersion} (https://github.com/avast/sage)`;
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
+ };
12639
13943
  }
12640
- function formatThreatBanner(version, results, versionCheck) {
12641
- const header = `\u{1F6E1}\uFE0F Sage v${version} by Gen Digital \u2014 Threat Detected`;
12642
- const lines = [" ", header, separatorLine(SEPARATOR_WIDTH)];
12643
- const MAX_FINDINGS = 5;
12644
- let first = true;
12645
- for (const result of results) {
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
+
14174
+ // ../core/dist/format.js
14175
+ var PAD = 12;
14176
+ var SEPARATOR_WIDTH = 48;
14177
+ function severityEmoji(severity) {
14178
+ const s = severity.toLowerCase();
14179
+ if (s === "critical" || s === "high")
14180
+ return "\u{1F6A8}";
14181
+ if (s === "medium" || s === "warn" || s === "warning")
14182
+ return "\u26A0\uFE0F";
14183
+ return "\u2139\uFE0F";
14184
+ }
14185
+ function kv(key, value) {
14186
+ return ` ${key.padEnd(PAD)}${value}`;
14187
+ }
14188
+ function separatorLine(headerLength) {
14189
+ return "\u2501".repeat(headerLength);
14190
+ }
14191
+ function formatUpdateNotice(result) {
14192
+ return `\u2B06\uFE0F Update available: v${result.currentVersion} \u2192 v${result.latestVersion} (https://github.com/avast/sage)`;
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
+ }
14202
+ function formatThreatBanner(version, results, versionCheck) {
14203
+ const header = `\u{1F6E1}\uFE0F Sage v${version} by Gen Digital \u2014 Threat Detected`;
14204
+ const lines = [" ", header, separatorLine(SEPARATOR_WIDTH)];
14205
+ const MAX_FINDINGS = 5;
14206
+ let first = true;
14207
+ for (const result of results) {
12646
14208
  const highCrit = result.findings.filter((f) => f.severity === "critical" || f.severity === "high");
12647
14209
  if (highCrit.length === 0)
12648
14210
  continue;
@@ -12673,31 +14235,203 @@ function formatThreatBanner(version, results, versionCheck) {
12673
14235
  }
12674
14236
  return lines.join("\n");
12675
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
+ }
12676
14244
 
12677
- // ../core/dist/plugin-scan-cache.js
12678
- 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");
12679
14379
  var import_promises4 = require("node:fs/promises");
12680
- var import_node_os3 = require("node:os");
12681
14380
  var import_node_path7 = require("node:path");
12682
- 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");
12683
14417
  var CACHE_TTL_DAYS = 7;
12684
14418
  function cacheKey(pluginKey, version, lastUpdated) {
12685
14419
  return `${pluginKey}:${version}:${lastUpdated}`;
12686
14420
  }
12687
14421
  async function computeConfigHash(sageVersion, ...dirs) {
12688
- const h = (0, import_node_crypto3.createHash)("sha256");
14422
+ const h = (0, import_node_crypto5.createHash)("sha256");
12689
14423
  if (sageVersion)
12690
14424
  h.update(sageVersion);
12691
14425
  for (const dir of dirs) {
12692
14426
  let files;
12693
14427
  try {
12694
- 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();
12695
14429
  } catch {
12696
14430
  continue;
12697
14431
  }
12698
14432
  for (const file of files) {
12699
14433
  try {
12700
- const content = await getFileContent((0, import_node_path7.join)(dir, file));
14434
+ const content = await getFileContent((0, import_node_path8.join)(dir, file));
12701
14435
  h.update(content);
12702
14436
  } catch {
12703
14437
  }
@@ -12787,11 +14521,11 @@ function storeResult(cache, pluginKey, version, lastUpdated, findings) {
12787
14521
  }
12788
14522
 
12789
14523
  // ../core/dist/plugin-scanner.js
12790
- var import_node_crypto4 = require("node:crypto");
12791
- var import_promises5 = require("node:fs/promises");
14524
+ var import_node_crypto6 = require("node:crypto");
14525
+ var import_promises6 = require("node:fs/promises");
12792
14526
  var import_node_os4 = require("node:os");
12793
- var import_node_path8 = require("node:path");
12794
- 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");
12795
14529
  var SCANNABLE_EXTENSIONS = /* @__PURE__ */ new Set([
12796
14530
  ".py",
12797
14531
  ".js",
@@ -12813,38 +14547,38 @@ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "__pycache__"])
12813
14547
  var MAX_FILE_SIZE = 512 * 1024;
12814
14548
  async function walkPluginFiles(installPath, logger) {
12815
14549
  const files = [];
12816
- async function walk(dir) {
12817
- let entries;
14550
+ async function walk(dirOrFile) {
14551
+ let stats;
12818
14552
  try {
12819
- entries = await (0, import_promises5.readdir)(dir);
14553
+ stats = await (0, import_promises6.stat)(dirOrFile);
12820
14554
  } catch {
12821
14555
  return;
12822
14556
  }
12823
- for (const entry of entries) {
12824
- if (SKIP_DIRS.has(entry))
12825
- continue;
12826
- const fullPath = (0, import_node_path8.join)(dir, entry);
12827
- 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;
12828
14565
  try {
12829
- stats = await (0, import_promises5.stat)(fullPath);
14566
+ entries = await (0, import_promises6.readdir)(dirOrFile);
12830
14567
  } catch {
12831
- continue;
14568
+ return;
12832
14569
  }
12833
- if (stats.isDirectory()) {
12834
- await walk(fullPath);
12835
- } else if (stats.isFile()) {
12836
- if (!SCANNABLE_EXTENSIONS.has((0, import_node_path8.extname)(fullPath).toLowerCase()))
14570
+ for (const entry of entries) {
14571
+ if (SKIP_DIRS.has(entry))
12837
14572
  continue;
12838
- if (stats.size > MAX_FILE_SIZE)
12839
- continue;
12840
- files.push(fullPath);
14573
+ const fullPath = (0, import_node_path9.join)(dirOrFile, entry);
14574
+ await walk(fullPath);
12841
14575
  }
12842
14576
  }
12843
14577
  }
12844
14578
  try {
12845
14579
  await walk(installPath);
12846
14580
  } catch (e) {
12847
- logger.warn(`Error walking plugin directory ${installPath}`, { error: String(e) });
14581
+ logger.warn(`Error walking plugin path ${installPath}`, { error: String(e) });
12848
14582
  }
12849
14583
  return files;
12850
14584
  }
@@ -12862,7 +14596,7 @@ function extractArtifactsFromFile(filePath, content) {
12862
14596
  continue;
12863
14597
  artifacts.push({ type: "url", value: url, context: `plugin_file:${fileName}` });
12864
14598
  }
12865
- const ext = (0, import_node_path8.extname)(filePath).toLowerCase();
14599
+ const ext = (0, import_node_path9.extname)(filePath).toLowerCase();
12866
14600
  if ([".sh", ".bash", ".zsh", ".py"].includes(ext)) {
12867
14601
  for (const line of content.split("\n")) {
12868
14602
  const trimmed = line.trim();
@@ -12907,7 +14641,7 @@ async function scanPlugin(plugin, threats, options = {}) {
12907
14641
  confidence: match.threat.confidence,
12908
14642
  action: match.threat.action,
12909
14643
  artifact: match.artifact.slice(0, 200),
12910
- sourceFile: (0, import_node_path8.relative)(plugin.installPath, filePath)
14644
+ sourceFile: (0, import_node_path9.relative)(plugin.installPath, filePath)
12911
14645
  });
12912
14646
  }
12913
14647
  }
@@ -12915,7 +14649,7 @@ async function scanPlugin(plugin, threats, options = {}) {
12915
14649
  allUrls.push(...extractUrls(content));
12916
14650
  }
12917
14651
  if (checkFileHashes) {
12918
- 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");
12919
14653
  const existing = hashToFiles.get(sha256);
12920
14654
  if (existing) {
12921
14655
  existing.push(filePath);
@@ -12962,7 +14696,7 @@ async function scanPlugin(plugin, threats, options = {}) {
12962
14696
  confidence: 1,
12963
14697
  action: "block",
12964
14698
  artifact: fr.sha256,
12965
- sourceFile: (0, import_node_path8.relative)(plugin.installPath, filePath)
14699
+ sourceFile: (0, import_node_path9.relative)(plugin.installPath, filePath)
12966
14700
  });
12967
14701
  }
12968
14702
  }
@@ -12998,18 +14732,63 @@ function toFindingData(finding) {
12998
14732
  };
12999
14733
  }
13000
14734
  function toAuditFindingData(finding) {
13001
- return {
13002
- threat_id: finding.threatId,
13003
- title: finding.title,
13004
- severity: finding.severity,
13005
- confidence: finding.confidence,
13006
- artifact: finding.artifact,
13007
- source_file: finding.sourceFile
13008
- };
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;
13009
14788
  }
13010
14789
 
13011
14790
  // ../core/dist/version-check.js
13012
- var GITHUB_RAW_URL = "https://raw.githubusercontent.com/avast/sage/main/packages/core/package.json";
14791
+ var import_node_os5 = require("node:os");
13013
14792
  var DEFAULT_TIMEOUT_MS = 5e3;
13014
14793
  function isNewerVersion(current, latest) {
13015
14794
  const parse = (v) => v.replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
@@ -13025,19 +14804,29 @@ function isNewerVersion(current, latest) {
13025
14804
  }
13026
14805
  return false;
13027
14806
  }
13028
- async function checkForUpdate(currentVersion, logger = nullLogger, timeoutMs = DEFAULT_TIMEOUT_MS) {
14807
+ async function checkForUpdate(currentVersion, logger = nullLogger, timeoutMs = DEFAULT_TIMEOUT_MS, context) {
13029
14808
  if (currentVersion === "dev") {
13030
14809
  logger.debug("Skipping version check for dev build");
13031
14810
  return null;
13032
14811
  }
13033
14812
  try {
13034
- const controller = new AbortController();
13035
- const timer = setTimeout(() => controller.abort(), timeoutMs);
13036
- const response = await fetch(GITHUB_RAW_URL, {
13037
- signal: controller.signal,
13038
- 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
+ })
13039
14829
  });
13040
- clearTimeout(timer);
13041
14830
  if (!response.ok) {
13042
14831
  logger.debug(`Version check HTTP ${response.status}`);
13043
14832
  return null;
@@ -13059,61 +14848,147 @@ async function checkForUpdate(currentVersion, logger = nullLogger, timeoutMs = D
13059
14848
  }
13060
14849
  }
13061
14850
 
13062
- // src/approval-store.ts
13063
- var DEFAULT_PATH = (0, import_node_path9.join)((0, import_node_os5.homedir)(), ".sage", "approvals.json");
13064
- var DEFAULT_TTL_SECONDS = 300;
13065
- var ApprovalStore = class {
13066
- data = {};
13067
- path;
13068
- logger;
13069
- constructor(logger, path = DEFAULT_PATH) {
13070
- this.logger = logger;
13071
- this.path = path;
13072
- }
13073
- 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 () => {
13074
14899
  try {
13075
- const raw = await getFileContent(this.path);
13076
- const parsed = JSON.parse(raw);
13077
- const now = Date.now();
13078
- this.data = {};
13079
- for (const [id, entry] of Object.entries(parsed)) {
13080
- if (entry && new Date(entry.expiresAt).getTime() > now) {
13081
- this.data[id] = entry;
13082
- }
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`);
13083
14904
  }
13084
- } catch {
13085
- 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) });
13086
14909
  }
13087
- }
13088
- isApproved(actionId) {
13089
- const entry = this.data[actionId];
13090
- if (!entry) return false;
13091
- if (new Date(entry.expiresAt).getTime() <= Date.now()) {
13092
- delete this.data[actionId];
13093
- 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 }] };
13094
14941
  }
13095
- return true;
13096
- }
13097
- async approve(actionId, ttlSeconds = DEFAULT_TTL_SECONDS) {
13098
- const now = /* @__PURE__ */ new Date();
13099
- this.data[actionId] = {
13100
- approvedAt: now.toISOString(),
13101
- expiresAt: new Date(now.getTime() + ttlSeconds * 1e3).toISOString()
13102
- };
13103
- await this.save();
13104
- }
13105
- async save() {
13106
- try {
13107
- await atomicWriteJson(this.path, this.data);
13108
- } catch (e) {
13109
- 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 }] };
13110
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";
13111
14989
  }
13112
- static actionId(toolName, params) {
13113
- const payload = JSON.stringify({ toolName, params });
13114
- return (0, import_node_crypto5.createHash)("sha256").update(payload).digest("hex");
13115
- }
13116
- };
14990
+ return cachedVersion;
14991
+ }
13117
14992
 
13118
14993
  // src/gate-tool.ts
13119
14994
  function createSageApproveTool(approvalStore) {
@@ -13135,14 +15010,15 @@ function createSageApproveTool(approvalStore) {
13135
15010
  required: ["actionId", "approved"]
13136
15011
  },
13137
15012
  async execute(_toolCallId, params) {
13138
- if (params.approved) {
13139
- await approvalStore.approve(params.actionId);
15013
+ if (!params.approved) {
15014
+ approvalStore.deletePending(params.actionId);
13140
15015
  return {
13141
- content: [{ type: "text", text: "Approved. Retry the tool call." }]
15016
+ content: [{ type: "text", text: "Rejected by user." }]
13142
15017
  };
13143
15018
  }
15019
+ const msg = await approveAction(approvalStore, params.actionId);
13144
15020
  return {
13145
- content: [{ type: "text", text: "Rejected by user." }]
15021
+ content: [{ type: "text", text: msg }]
13146
15022
  };
13147
15023
  }
13148
15024
  };
@@ -13169,30 +15045,8 @@ function createLogger(pluginLogger) {
13169
15045
  };
13170
15046
  }
13171
15047
 
13172
- // src/bundled-dirs.ts
13173
- var import_node_fs = require("node:fs");
13174
- var import_node_path10 = require("node:path");
13175
- var packageRoot = (0, import_node_path10.resolve)(__dirname, "..");
13176
- function getBundledDataDirs() {
13177
- return {
13178
- threatsDir: (0, import_node_path10.join)(packageRoot, "resources", "threats"),
13179
- allowlistsDir: (0, import_node_path10.join)(packageRoot, "resources", "allowlists")
13180
- };
13181
- }
13182
- var cachedVersion;
13183
- function getSageVersion() {
13184
- if (cachedVersion) return cachedVersion;
13185
- try {
13186
- const pkg = JSON.parse((0, import_node_fs.readFileSync)((0, import_node_path10.join)(packageRoot, "package.json"), "utf-8"));
13187
- cachedVersion = pkg.version ?? "0.0.0";
13188
- } catch {
13189
- cachedVersion = "0.0.0";
13190
- }
13191
- return cachedVersion;
13192
- }
13193
-
13194
15048
  // src/plugin-discovery.ts
13195
- var import_promises6 = require("node:fs/promises");
15049
+ var import_promises7 = require("node:fs/promises");
13196
15050
  var import_node_os6 = require("node:os");
13197
15051
  var import_node_path11 = require("node:path");
13198
15052
  var DEFAULT_EXTENSIONS_DIR = (0, import_node_path11.join)((0, import_node_os6.homedir)(), ".openclaw", "extensions");
@@ -13200,7 +15054,7 @@ async function discoverOpenClawPlugins(logger, extensionsDir = DEFAULT_EXTENSION
13200
15054
  logger.info("Sage plugin discovery: scanning extensions directory", { path: extensionsDir });
13201
15055
  let entries;
13202
15056
  try {
13203
- entries = await (0, import_promises6.readdir)(extensionsDir);
15057
+ entries = await (0, import_promises7.readdir)(extensionsDir);
13204
15058
  } catch {
13205
15059
  logger.debug("OpenClaw extensions directory not found", { path: extensionsDir });
13206
15060
  return [];
@@ -13210,7 +15064,7 @@ async function discoverOpenClawPlugins(logger, extensionsDir = DEFAULT_EXTENSION
13210
15064
  const extDir = (0, import_node_path11.join)(extensionsDir, entry);
13211
15065
  let stats;
13212
15066
  try {
13213
- stats = await (0, import_promises6.stat)(extDir);
15067
+ stats = await (0, import_promises7.stat)(extDir);
13214
15068
  if (!stats.isDirectory()) continue;
13215
15069
  } catch {
13216
15070
  continue;
@@ -13240,111 +15094,26 @@ async function discoverOpenClawPlugins(logger, extensionsDir = DEFAULT_EXTENSION
13240
15094
  }
13241
15095
 
13242
15096
  // src/startup-scan.ts
13243
- var SAGE_PLUGIN_ID = "sage-openclaw";
13244
- async function runScan(logger, context) {
13245
- await pruneOrphanedTmpFiles(resolvePath("~/.sage"));
15097
+ function createOpenClawScanHandler(logger, context, onResult) {
13246
15098
  const { threatsDir, allowlistsDir } = getBundledDataDirs();
13247
15099
  const version = getSageVersion();
13248
- logger.info(`Sage plugin scan started (${context})`, { threatsDir, allowlistsDir });
13249
- const [threats, trustedDomains, versionCheck] = await Promise.all([
13250
- loadThreats(threatsDir, logger),
13251
- loadTrustedDomains(allowlistsDir, logger),
13252
- checkForUpdate(version, logger)
13253
- ]);
13254
- const updateNotice = versionCheck?.updateAvailable ? formatUpdateNotice(versionCheck) : null;
13255
- if (threats.length === 0) {
13256
- logger.warn(`Sage plugin scan (${context}): no threats loaded, skipping`);
13257
- return updateNotice;
13258
- }
13259
- logger.info(`Sage plugin scan (${context}): loaded ${threats.length} threat definitions`);
13260
- let plugins = await discoverOpenClawPlugins(logger);
13261
- plugins = plugins.filter((p) => !p.key.startsWith(`${SAGE_PLUGIN_ID}@`));
13262
- if (plugins.length === 0) {
13263
- logger.warn(`Sage plugin scan (${context}): no plugins to scan after filtering`);
13264
- return updateNotice;
13265
- }
13266
- logger.info(`Sage plugin scan (${context}): ${plugins.length} plugin(s) to scan`, {
13267
- 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
13268
15110
  });
13269
- const configHash = await computeConfigHash("", threatsDir, allowlistsDir);
13270
- const cache = await loadScanCache(configHash, void 0, logger);
13271
- const resultsWithFindings = [];
13272
- let cacheModified = false;
13273
- let scannedCount = 0;
13274
- for (const plugin of plugins) {
13275
- const cached = getCached(cache, plugin.key, plugin.version, plugin.lastUpdated);
13276
- if (cached && cached.findings.length === 0) {
13277
- logger.info(`Sage plugin scan (${context}): cache hit (clean) for ${plugin.key}`);
13278
- continue;
13279
- }
13280
- if (cached && cached.findings.length > 0) {
13281
- logger.info(
13282
- `Sage plugin scan (${context}): cache hit with ${cached.findings.length} finding(s) for ${plugin.key}`
13283
- );
13284
- resultsWithFindings.push({ plugin, findings: cached.findings.map(fromCachedFinding) });
13285
- continue;
13286
- }
13287
- logger.info(`Sage plugin scan (${context}): scanning ${plugin.key}`);
13288
- const result = await scanPlugin(plugin, threats, {
13289
- checkUrls: true,
13290
- trustedDomains,
13291
- logger
13292
- });
13293
- scannedCount++;
13294
- storeResult(
13295
- cache,
13296
- plugin.key,
13297
- plugin.version,
13298
- plugin.lastUpdated,
13299
- result.findings.map(toFindingData)
13300
- );
13301
- cacheModified = true;
13302
- if (result.findings.length > 0) {
13303
- resultsWithFindings.push(result);
13304
- }
13305
- }
13306
- if (cacheModified) {
13307
- await saveScanCache(cache, void 0, logger);
13308
- }
13309
- logger.info(
13310
- `Sage plugin scan (${context}) complete: ${scannedCount} scanned, ${resultsWithFindings.length} with findings, cache ${cacheModified ? "updated" : "unchanged"}`
13311
- );
13312
- try {
13313
- const sageConfig = await loadConfig(void 0, logger);
13314
- for (const result of resultsWithFindings) {
13315
- await logPluginScan(
13316
- sageConfig.logging,
13317
- result.plugin.key,
13318
- result.plugin.version,
13319
- result.findings.map(toAuditFindingData)
13320
- );
13321
- }
13322
- } catch {
13323
- }
13324
- if (resultsWithFindings.length > 0) {
13325
- const banner = formatThreatBanner(version, resultsWithFindings, versionCheck);
13326
- logger.warn(`Sage: threat findings detected`, {
13327
- plugins: resultsWithFindings.map((r) => r.plugin.key)
13328
- });
13329
- return banner;
13330
- }
13331
- return updateNotice;
13332
15111
  }
13333
- function createScanHandler(logger, context, onFindings) {
13334
- return async () => {
13335
- try {
13336
- const findings = await runScan(logger, context);
13337
- onFindings?.(findings);
13338
- } catch (e) {
13339
- logger.error(`Sage ${context} scan failed`, { error: String(e) });
13340
- }
13341
- };
15112
+ function createStartupScanHandler(logger, onResult) {
15113
+ return createOpenClawScanHandler(logger, "startup", onResult);
13342
15114
  }
13343
- function createStartupScanHandler(logger, onFindings) {
13344
- return createScanHandler(logger, "startup", onFindings);
13345
- }
13346
- function createSessionScanHandler(logger, onFindings) {
13347
- return createScanHandler(logger, "session", onFindings);
15115
+ function createSessionScanHandler(logger, onResult) {
15116
+ return createOpenClawScanHandler(logger, "session", onResult);
13348
15117
  }
13349
15118
  function createBeforeAgentStartHandler(getFindings, clearFindings, logger) {
13350
15119
  return () => {
@@ -13398,153 +15167,32 @@ function mapToolToArtifacts(toolName, params) {
13398
15167
  return null;
13399
15168
  }
13400
15169
  }
13401
- function createToolCallHandler(approvalStore, logger, sessionKey = "unknown") {
13402
- return async (event) => {
15170
+ function createToolCallHandler(approvalStore, logger, threatsDir, allowlistsDir) {
15171
+ async function handleToolCall(event, ctx) {
13403
15172
  try {
13404
15173
  const { toolName, params } = event;
13405
15174
  const artifacts = mapToolToArtifacts(toolName, params);
13406
15175
  if (!artifacts || artifacts.length === 0) return void 0;
13407
- const actionId = ApprovalStore.actionId(toolName, params);
13408
- if (approvalStore.isApproved(actionId)) return void 0;
13409
- const config = await loadConfig(void 0, logger).catch(
13410
- () => 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
13411
15181
  );
13412
- try {
13413
- const allowlist = await loadAllowlist(config.allowlist, logger);
13414
- if (isAllowlisted(allowlist, artifacts)) {
13415
- await logVerdict(
13416
- config.logging,
13417
- sessionKey,
13418
- toolName,
13419
- params,
13420
- {
13421
- decision: "allow",
13422
- category: "none",
13423
- confidence: 1,
13424
- severity: "info",
13425
- source: "allowlisted",
13426
- artifacts: [],
13427
- matchedThreatId: null,
13428
- reasons: []
13429
- },
13430
- true
13431
- );
13432
- return void 0;
13433
- }
13434
- } catch {
13435
- }
13436
- let cache = null;
13437
- try {
13438
- cache = new VerdictCache(config.cache, logger);
13439
- await cache.load();
13440
- } catch {
13441
- cache = null;
13442
- }
13443
- const urls = artifacts.filter((a) => a.type === "url").map((a) => a.value);
13444
- const cachedUrlVerdicts = /* @__PURE__ */ new Map();
13445
- let uncachedUrls = [];
13446
- if (cache && urls.length > 0) {
13447
- try {
13448
- for (const url of urls) {
13449
- const cached = cache.getUrl(url);
13450
- if (cached) {
13451
- cachedUrlVerdicts.set(url, cached);
13452
- } else {
13453
- uncachedUrls.push(url);
13454
- }
13455
- }
13456
- } catch {
13457
- uncachedUrls = urls;
13458
- }
13459
- }
13460
- const { threatsDir, allowlistsDir } = getBundledDataDirs();
13461
- let heuristicMatches = [];
13462
- if (config.heuristics_enabled) {
13463
- const threats = await loadThreats(threatsDir, logger);
13464
- const trustedDomains = await loadTrustedDomains(allowlistsDir, logger);
13465
- const heuristics = new HeuristicsEngine(threats, trustedDomains);
13466
- heuristicMatches = heuristics.match(artifacts);
13467
- }
13468
- let urlCheckResults = [];
13469
- const urlsToCheck = cache ? uncachedUrls : urls;
13470
- if (urlsToCheck.length > 0 && config.url_check.enabled) {
13471
- try {
13472
- const client = new UrlCheckClient(config.url_check, logger);
13473
- urlCheckResults = await client.checkUrls(urlsToCheck);
13474
- } catch {
13475
- }
13476
- }
13477
- const engine = new DecisionEngine(config.sensitivity);
13478
- let verdict = await engine.decide(heuristicMatches, urlCheckResults);
13479
- if (cachedUrlVerdicts.size > 0 && verdict.decision === "allow") {
13480
- for (const [url, cv] of cachedUrlVerdicts) {
13481
- if (cv.verdict !== "allow") {
13482
- verdict = {
13483
- decision: cv.verdict,
13484
- category: "network_egress",
13485
- confidence: 1,
13486
- severity: cv.severity,
13487
- source: `cache(${cv.source})`,
13488
- artifacts: [url],
13489
- matchedThreatId: null,
13490
- reasons: cv.reasons
13491
- };
13492
- break;
13493
- }
13494
- }
13495
- }
13496
- if (cache) {
13497
- try {
13498
- for (const result of urlCheckResults) {
13499
- let cv;
13500
- if (result.isMalicious) {
13501
- cv = {
13502
- verdict: "deny",
13503
- severity: "critical",
13504
- reasons: [
13505
- `URL check: malicious (${result.findings.map((f) => `${f.severityName}/${f.typeName}`).join(", ")})`
13506
- ],
13507
- source: "url_check"
13508
- };
13509
- } else if (result.flags.length > 0) {
13510
- cv = {
13511
- verdict: "ask",
13512
- severity: "warning",
13513
- reasons: [`URL check: suspicious (${result.flags.join(", ")})`],
13514
- source: "url_check"
13515
- };
13516
- } else {
13517
- cv = {
13518
- verdict: "allow",
13519
- severity: "info",
13520
- reasons: [],
13521
- source: "url_check"
13522
- };
13523
- }
13524
- cache.putUrl(result.url, cv, result.isMalicious);
13525
- }
13526
- await cache.save();
13527
- } catch {
13528
- }
13529
- }
13530
- try {
13531
- await logVerdict(config.logging, sessionKey, toolName, params, verdict);
13532
- } catch {
13533
- }
13534
15182
  if (verdict.decision === "allow") return void 0;
13535
- const reasonText = verdict.reasons.length > 0 ? verdict.reasons.slice(0, 5).join("; ") : verdict.category;
13536
15183
  if (verdict.decision === "deny") {
13537
- return { block: true, blockReason: `Sage blocked: ${reasonText}` };
15184
+ return { block: true, blockReason: formatDenyMessage(verdict) };
13538
15185
  }
13539
15186
  return {
13540
15187
  block: true,
13541
- 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)
13542
15189
  };
13543
15190
  } catch (e) {
13544
15191
  logger.error("tool-handler error, failing open", { error: String(e) });
13545
15192
  return void 0;
13546
15193
  }
13547
- };
15194
+ }
15195
+ return handleToolCall;
13548
15196
  }
13549
15197
 
13550
15198
  // src/index.ts
@@ -13557,15 +15205,25 @@ var index_default = {
13557
15205
  },
13558
15206
  register(api) {
13559
15207
  const logger = createLogger(api.logger);
13560
- 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();
13561
15213
  let pendingFindings = null;
13562
15214
  const onFindings = (msg) => {
13563
- if (msg) pendingFindings = msg;
15215
+ pendingFindings = msg;
13564
15216
  };
13565
- api.on("before_tool_call", createToolCallHandler(approvalStore, logger), {
13566
- priority: 100
13567
- });
15217
+ api.on(
15218
+ "before_tool_call",
15219
+ createToolCallHandler(approvalStore, logger, threatsDir, allowlistsDir),
15220
+ { priority: 100 }
15221
+ );
13568
15222
  api.registerTool(createSageApproveTool(approvalStore));
15223
+ api.registerTool(
15224
+ createAllowlistAddTool(approvalStore, logger)
15225
+ );
15226
+ api.registerTool(createAllowlistRemoveTool(logger));
13569
15227
  api.on("gateway_start", createStartupScanHandler(logger, onFindings));
13570
15228
  api.on("session_start", createSessionScanHandler(logger, onFindings));
13571
15229
  api.on(