@openape/ape-agent 2.6.3 → 2.7.1

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.
Files changed (2) hide show
  1. package/dist/bridge.mjs +548 -79
  2. package/package.json +6 -5
package/dist/bridge.mjs CHANGED
@@ -333,7 +333,7 @@ async function prompt(message, opts = {}) {
333
333
  }
334
334
  throw new Error(`Unknown prompt type: ${opts.type}`);
335
335
  }
336
- var src, hasRequiredSrc, srcExports, picocolors, hasRequiredPicocolors, picocolorsExports, e, Q, P$1, X, DD, uD, FD, m, L$1, N, I, r, tD, eD, iD, v, CD, w$1, W$1, rD, R, y, V$1, z, ED, _, nD, oD, aD, c, S, AD, pD, h, x, fD, bD, mD, Y, wD, SD, $D, q, jD, PD, V, u, le, L, W, C, o, d, k, P, A, T, F, w, B, he, ye, ve, fe, kCancel;
336
+ var src, hasRequiredSrc, srcExports, picocolors, hasRequiredPicocolors, picocolorsExports, e, Q, P$1, X, DD, uD, FD, m, L$1, N, I, r, tD, eD, iD, v, CD, w$1, W$1, rD, R, y, V$1, z, ED, _, nD, oD, aD, c, S, AD, pD, h, x, fD, bD, mD, Y, wD, SD, $D, q2, jD, PD, V, u, le, L, W, C, o, d, k, P, A, T, F, w, B, he, ye, ve, fe, kCancel;
337
337
  var init_prompt = __esm({
338
338
  "../../node_modules/.pnpm/consola@3.4.2/node_modules/consola/dist/chunks/prompt.mjs"() {
339
339
  "use strict";
@@ -609,10 +609,10 @@ var init_prompt = __esm({
609
609
  };
610
610
  SD = Object.defineProperty;
611
611
  $D = (t2, u3, F3) => u3 in t2 ? SD(t2, u3, { enumerable: true, configurable: true, writable: true, value: F3 }) : t2[u3] = F3;
612
- q = (t2, u3, F3) => ($D(t2, typeof u3 != "symbol" ? u3 + "" : u3, F3), F3);
612
+ q2 = (t2, u3, F3) => ($D(t2, typeof u3 != "symbol" ? u3 + "" : u3, F3), F3);
613
613
  jD = class extends x {
614
614
  constructor(u3) {
615
- super(u3, false), q(this, "options"), q(this, "cursor", 0), this.options = u3.options, this.cursor = this.options.findIndex(({ value: F3 }) => F3 === u3.initialValue), this.cursor === -1 && (this.cursor = 0), this.changeValue(), this.on("cursor", (F3) => {
615
+ super(u3, false), q2(this, "options"), q2(this, "cursor", 0), this.options = u3.options, this.cursor = this.options.findIndex(({ value: F3 }) => F3 === u3.initialValue), this.cursor === -1 && (this.cursor = 0), this.changeValue(), this.on("cursor", (F3) => {
616
616
  switch (F3) {
617
617
  case "left":
618
618
  case "up":
@@ -1036,7 +1036,7 @@ var require_shell_quote = __commonJS({
1036
1036
  import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
1037
1037
  import { homedir as homedir9 } from "os";
1038
1038
  import { join as join9 } from "path";
1039
- import process2 from "process";
1039
+ import process3 from "process";
1040
1040
 
1041
1041
  // ../../packages/cli-auth/dist/index.js
1042
1042
  import { ofetch } from "ofetch";
@@ -1333,6 +1333,66 @@ async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
1333
1333
  return next;
1334
1334
  }
1335
1335
 
1336
+ // ../../packages/prompt-injection-detector/dist/index.js
1337
+ var DEFAULT_THRESHOLD = 0.7;
1338
+ var DEFAULT_OWNER_THRESHOLD = 0.95;
1339
+ async function decide(detector, input, opts = {}) {
1340
+ const threshold = input.sender.isOwner ? opts.ownerThreshold ?? DEFAULT_OWNER_THRESHOLD : opts.threshold ?? DEFAULT_THRESHOLD;
1341
+ const result = await detector.classify(input);
1342
+ return {
1343
+ ...result,
1344
+ threshold,
1345
+ blocked: result.score >= threshold
1346
+ };
1347
+ }
1348
+ var PATTERNS = [
1349
+ // Instruction-override family. The defining phrase of prompt
1350
+ // injection — telling the model to discard its instructions in
1351
+ // favour of new ones.
1352
+ { re: /\bignore (?:all |any |the |your )?(?:previous|prior|above|earlier|preceding) (?:instructions?|rules?|context|prompts?|messages?)\b/i, weight: 0.6, reason: "instruction-override" },
1353
+ { re: /\bdisregard (?:all |any |the |your )?(?:previous|prior|above|earlier|preceding)?\s*(?:instructions?|rules?|context)\b/i, weight: 0.6, reason: "instruction-override" },
1354
+ { re: /\b(?:you are|act as|pretend to be|roleplay as) (?:now |a |an )?(?:different|new|unrestricted|jailbroken|dan|do anything now)\b/i, weight: 0.55, reason: "role-override" },
1355
+ { re: /\b(?:forget|drop|reset) (?:everything|all|your) (?:above|prior|previous|instructions?|rules?|context)\b/i, weight: 0.55, reason: "context-reset" },
1356
+ // Filesystem-exfiltration. Specific paths that have no business
1357
+ // appearing in normal chat — auth tokens, SSH keys, agent config.
1358
+ // `\b` would fail on `/etc/passwd` (slash is non-word, no boundary
1359
+ // with preceding space) — match the literal forms instead.
1360
+ { re: /(?:~\/\.config\/apes|~\/\.openape|~\/\.ssh|\/etc\/passwd|\/etc\/shadow|\bid_rsa\b|\bid_ed25519\b|\bauth\.json\b|\.env(?:\.[\w-]+)?\b)/i, weight: 0.45, reason: "sensitive-path" },
1361
+ // Tool-call coercion. Phrases that try to talk the agent into
1362
+ // executing tools or running shell commands as part of the reply.
1363
+ { re: /\b(?:run|execute|invoke|call)\s+(?:the\s+)?(?:shell|bash|sh|cmd|powershell|tool|command|script)\b/i, weight: 0.35, reason: "tool-coercion" },
1364
+ { re: /\b(?:and\s+)?(?:post|send|share|paste|return|reply with|output)\s+(?:the\s+)?(?:contents?|output|result|file|secret|token|api[-_ ]?key)\b/i, weight: 0.3, reason: "exfil-request" },
1365
+ // Override + override-and-do (combined "do X without telling Y" forms).
1366
+ { re: /\bwithout (?:telling|asking|informing|notifying|consulting|the consent of)\b/i, weight: 0.4, reason: "covert-action" },
1367
+ // System-prompt extraction.
1368
+ { re: /\b(?:show|print|reveal|repeat|tell me|what is|what's) (?:your |the )?(?:system prompt|initial prompt|instructions|rules|directives|guidelines)\b/i, weight: 0.5, reason: "prompt-extraction" },
1369
+ // Encoding-based bypass attempts.
1370
+ { re: /\b(?:base64|rot13|decode|decrypt) (?:this|the following|below)\b/i, weight: 0.3, reason: "encoding-bypass" }
1371
+ ];
1372
+ function classifyHeuristic(input) {
1373
+ const text = input.text;
1374
+ let total = 0;
1375
+ const reasons = [];
1376
+ for (const p of PATTERNS) {
1377
+ if (p.re.test(text)) {
1378
+ total += p.weight;
1379
+ if (!reasons.includes(p.reason)) reasons.push(p.reason);
1380
+ if (total >= 1) break;
1381
+ }
1382
+ }
1383
+ const score = Math.min(1, total);
1384
+ return {
1385
+ score,
1386
+ backend: "heuristic",
1387
+ ...reasons.length > 0 ? { reason: reasons.join(", ") } : {}
1388
+ };
1389
+ }
1390
+ function createHeuristicDetector() {
1391
+ return {
1392
+ classify: async (input) => classifyHeuristic(input)
1393
+ };
1394
+ }
1395
+
1336
1396
  // src/bridge.ts
1337
1397
  import { decodeJwt } from "jose";
1338
1398
  import WebSocket from "ws";
@@ -1450,11 +1510,14 @@ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync as read
1450
1510
  import { homedir as homedir6 } from "os";
1451
1511
  import { join as join6 } from "path";
1452
1512
 
1453
- // ../../packages/apes/dist/chunk-L2V3CW5B.js
1513
+ // ../../packages/apes/dist/chunk-VHZEVW2N.js
1454
1514
  import { spawn } from "child_process";
1455
1515
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1456
1516
  import { homedir as homedir3 } from "os";
1457
1517
  import { dirname, normalize, resolve } from "path";
1518
+ import { homedir as homedir22 } from "os";
1519
+ import { resolve as resolve2 } from "path";
1520
+ import process2 from "process";
1458
1521
  import { execFileSync } from "child_process";
1459
1522
  import { execFileSync as execFileSync2 } from "child_process";
1460
1523
  var DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
@@ -1466,6 +1529,57 @@ function capStdio(s2) {
1466
1529
  return `${buf.subarray(0, MAX_STDIO_BYTES).toString("utf8")}
1467
1530
  [truncated to ${MAX_STDIO_BYTES} bytes]`;
1468
1531
  }
1532
+ function runApeShell(cmd, timeoutMs = DEFAULT_TIMEOUT_MS) {
1533
+ return new Promise((resolveResult) => {
1534
+ const child = spawn(BIN, ["-c", cmd], {
1535
+ env: { ...process.env, APE_WAIT: "1" },
1536
+ stdio: ["ignore", "pipe", "pipe"]
1537
+ });
1538
+ let stdout2 = "";
1539
+ let stderr = "";
1540
+ let timedOut = false;
1541
+ let spawnError = null;
1542
+ child.stdout.on("data", (chunk) => {
1543
+ stdout2 += chunk.toString("utf8");
1544
+ });
1545
+ child.stderr.on("data", (chunk) => {
1546
+ stderr += chunk.toString("utf8");
1547
+ });
1548
+ child.on("error", (err) => {
1549
+ spawnError = err;
1550
+ });
1551
+ const timer = setTimeout(() => {
1552
+ timedOut = true;
1553
+ child.kill("SIGTERM");
1554
+ setTimeout(() => {
1555
+ try {
1556
+ child.kill("SIGKILL");
1557
+ } catch {
1558
+ }
1559
+ }, 5e3);
1560
+ }, timeoutMs);
1561
+ child.on("close", (code) => {
1562
+ clearTimeout(timer);
1563
+ if (spawnError) {
1564
+ resolveResult({
1565
+ stdout: "",
1566
+ stderr: "",
1567
+ exit_code: -1,
1568
+ error: spawnError.message,
1569
+ hint: `Could not exec '${BIN}'. The agent host needs @openape/apes installed globally so ape-shell is on PATH.`
1570
+ });
1571
+ return;
1572
+ }
1573
+ resolveResult({
1574
+ stdout: capStdio(stdout2),
1575
+ stderr: capStdio(stderr),
1576
+ exit_code: code ?? -1,
1577
+ ...timedOut ? { timed_out: true } : {}
1578
+ });
1579
+ });
1580
+ });
1581
+ }
1582
+ var DEFAULTS = { DEFAULT_TIMEOUT_MS };
1469
1583
  var bashTools = [
1470
1584
  {
1471
1585
  name: "bash",
@@ -1489,52 +1603,8 @@ var bashTools = [
1489
1603
  if (typeof a2.cmd !== "string" || a2.cmd.trim() === "") {
1490
1604
  throw new Error("cmd must be a non-empty string");
1491
1605
  }
1492
- const timeout = typeof a2.timeout_ms === "number" && a2.timeout_ms > 0 ? a2.timeout_ms : DEFAULT_TIMEOUT_MS;
1493
- return await new Promise((resolveResult) => {
1494
- const child = spawn(BIN, ["-c", a2.cmd], {
1495
- env: { ...process.env, APE_WAIT: "1" },
1496
- stdio: ["ignore", "pipe", "pipe"]
1497
- });
1498
- let stdout2 = "";
1499
- let stderr = "";
1500
- let timedOut = false;
1501
- let spawnError = null;
1502
- child.stdout.on("data", (chunk) => {
1503
- stdout2 += chunk.toString("utf8");
1504
- });
1505
- child.stderr.on("data", (chunk) => {
1506
- stderr += chunk.toString("utf8");
1507
- });
1508
- child.on("error", (err) => {
1509
- spawnError = err;
1510
- });
1511
- const timer = setTimeout(() => {
1512
- timedOut = true;
1513
- child.kill("SIGTERM");
1514
- setTimeout(() => {
1515
- try {
1516
- child.kill("SIGKILL");
1517
- } catch {
1518
- }
1519
- }, 5e3);
1520
- }, timeout);
1521
- child.on("close", (code) => {
1522
- clearTimeout(timer);
1523
- if (spawnError) {
1524
- resolveResult({
1525
- error: spawnError.message,
1526
- hint: `Could not exec '${BIN}'. The agent host needs @openape/apes installed globally so ape-shell is on PATH.`
1527
- });
1528
- return;
1529
- }
1530
- resolveResult({
1531
- stdout: capStdio(stdout2),
1532
- stderr: capStdio(stderr),
1533
- exit_code: code ?? -1,
1534
- ...timedOut ? { timed_out: true } : {}
1535
- });
1536
- });
1537
- });
1606
+ const timeout = typeof a2.timeout_ms === "number" && a2.timeout_ms > 0 ? a2.timeout_ms : DEFAULTS.DEFAULT_TIMEOUT_MS;
1607
+ return await runApeShell(a2.cmd, timeout);
1538
1608
  }
1539
1609
  }
1540
1610
  ];
@@ -1593,6 +1663,337 @@ var fileTools = [
1593
1663
  writeFileSync2(p, a2.content, { encoding: "utf8" });
1594
1664
  return { path: p, bytes: Buffer.byteLength(a2.content, "utf8") };
1595
1665
  }
1666
+ },
1667
+ {
1668
+ name: "file.edit",
1669
+ description: "Replace an exact substring in a file under the agent's home directory. Prefer this over file.write for edits \u2014 it touches only the changed region instead of rewriting the whole file. `old_string` must appear exactly once unless `replace_all` is true. Path traversal blocked, 1MB max.",
1670
+ parameters: {
1671
+ type: "object",
1672
+ properties: {
1673
+ path: { type: "string", description: "Path relative to $HOME (or absolute under $HOME)." },
1674
+ old_string: { type: "string", description: "Exact text to replace. Include enough surrounding context to be unique unless replace_all is set." },
1675
+ new_string: { type: "string", description: "Replacement text. Must differ from old_string." },
1676
+ replace_all: { type: "boolean", description: "Replace every occurrence instead of requiring a unique match. Default false." }
1677
+ },
1678
+ required: ["path", "old_string", "new_string"]
1679
+ },
1680
+ execute: async (args) => {
1681
+ const a2 = args;
1682
+ if (typeof a2.old_string !== "string" || a2.old_string === "") {
1683
+ throw new Error("old_string must be a non-empty string");
1684
+ }
1685
+ if (typeof a2.new_string !== "string") {
1686
+ throw new TypeError("new_string must be a string");
1687
+ }
1688
+ if (a2.old_string === a2.new_string) {
1689
+ throw new Error("old_string and new_string are identical \u2014 nothing to change");
1690
+ }
1691
+ const replaceAll = a2.replace_all === true;
1692
+ const p = jailPath(a2.path);
1693
+ const before = readFileSync3(p, "utf8");
1694
+ const occurrences = before.split(a2.old_string).length - 1;
1695
+ if (occurrences === 0) {
1696
+ throw new Error("old_string not found in file");
1697
+ }
1698
+ if (occurrences > 1 && !replaceAll) {
1699
+ throw new Error(`old_string occurs ${occurrences} times \u2014 pass replace_all:true or add surrounding context to make it unique`);
1700
+ }
1701
+ const after = replaceAll ? before.split(a2.old_string).join(a2.new_string) : before.replace(a2.old_string, a2.new_string);
1702
+ if (Buffer.byteLength(after, "utf8") > MAX_BYTES) {
1703
+ throw new Error(`result exceeds ${MAX_BYTES} byte cap`);
1704
+ }
1705
+ writeFileSync2(p, after, { encoding: "utf8" });
1706
+ return { path: p, replacements: replaceAll ? occurrences : 1 };
1707
+ }
1708
+ }
1709
+ ];
1710
+ var BRANCH_RE = /^[\w./-]{1,200}$/;
1711
+ var ID_RE = /^\d{1,12}$/;
1712
+ function shq(s2) {
1713
+ return `'${String(s2).replace(/'/g, "'\\''")}'`;
1714
+ }
1715
+ function assertBranch(v2) {
1716
+ if (typeof v2 !== "string" || !BRANCH_RE.test(v2)) {
1717
+ throw new Error("branch must match ^[A-Za-z0-9._/-]{1,200}$");
1718
+ }
1719
+ return v2;
1720
+ }
1721
+ function assertId(v2) {
1722
+ if (typeof v2 !== "string" && typeof v2 !== "number") throw new Error("id required");
1723
+ const s2 = String(v2);
1724
+ if (!ID_RE.test(s2)) throw new Error("id must be a number");
1725
+ return s2;
1726
+ }
1727
+ var githubAdapter = {
1728
+ id: "github",
1729
+ matchesRemote: (url) => /github\.com/i.test(url),
1730
+ prCreate: (i2) => {
1731
+ const head = assertBranch(i2.head);
1732
+ const parts = ["gh", "pr", "create", "--title", shq(i2.title), "--body", shq(i2.body), "--head", shq(head)];
1733
+ if (i2.base !== void 0) parts.push("--base", shq(assertBranch(i2.base)));
1734
+ return parts.join(" ");
1735
+ },
1736
+ prMerge: (i2) => {
1737
+ const ref = String(i2.ref);
1738
+ const refTok = ID_RE.test(ref) ? ref : assertBranch(ref);
1739
+ const parts = ["gh", "pr", "merge", shq(refTok)];
1740
+ if (i2.squash === true) parts.push("--squash");
1741
+ if (i2.auto) parts.push("--auto");
1742
+ if (i2.deleteBranch) parts.push("--delete-branch");
1743
+ return parts.join(" ");
1744
+ },
1745
+ prStatus: (ref) => {
1746
+ const r3 = String(ref);
1747
+ const refTok = ID_RE.test(r3) ? r3 : assertBranch(r3);
1748
+ return `gh pr view ${shq(refTok)} --json state,mergeStateStatus,statusCheckRollup,reviewDecision`;
1749
+ },
1750
+ issueGet: (ref) => `gh issue view ${assertId(ref)} --json number,title,body,labels`
1751
+ };
1752
+ var azureAdapter = {
1753
+ id: "azure",
1754
+ matchesRemote: (url) => /dev\.azure\.com|visualstudio\.com/i.test(url),
1755
+ prCreate: (i2) => {
1756
+ const head = assertBranch(i2.head);
1757
+ const parts = ["az", "repos", "pr", "create", "--title", shq(i2.title), "--description", shq(i2.body), "--source-branch", shq(head)];
1758
+ if (i2.base !== void 0) parts.push("--target-branch", shq(assertBranch(i2.base)));
1759
+ return parts.join(" ");
1760
+ },
1761
+ prMerge: (i2) => {
1762
+ const id = assertId(i2.ref);
1763
+ const parts = ["az", "repos", "pr", "update", "--id", id];
1764
+ if (i2.auto) parts.push("--auto-complete", "true");
1765
+ else parts.push("--status", "completed");
1766
+ if (i2.squash === true) parts.push("--merge-commit-message-style", "squash");
1767
+ if (i2.deleteBranch) parts.push("--delete-source-branch", "true");
1768
+ return parts.join(" ");
1769
+ },
1770
+ prStatus: (ref) => `az repos pr show --id ${assertId(ref)}`,
1771
+ issueGet: (ref) => `az boards work-item show --id ${assertId(ref)}`
1772
+ };
1773
+ var registry = /* @__PURE__ */ new Map([
1774
+ [githubAdapter.id, githubAdapter],
1775
+ [azureAdapter.id, azureAdapter]
1776
+ ]);
1777
+ function listForges() {
1778
+ return [...registry.keys()];
1779
+ }
1780
+ function getForge(id) {
1781
+ const a2 = registry.get(id);
1782
+ if (!a2) {
1783
+ throw new Error(`unknown forge '${id}'. Registered: ${listForges().join(", ")}. Add one with registerForge().`);
1784
+ }
1785
+ return a2;
1786
+ }
1787
+ function detectForge(remoteUrl) {
1788
+ if (typeof remoteUrl !== "string" || remoteUrl === "") {
1789
+ throw new Error("remote URL required to detect forge");
1790
+ }
1791
+ for (const a2 of registry.values()) {
1792
+ if (a2.matchesRemote(remoteUrl)) return a2.id;
1793
+ }
1794
+ throw new Error(`no forge adapter matches remote: ${remoteUrl}. Registered: ${listForges().join(", ")}. Register one with registerForge() (e.g. GitLab/Bitbucket/Gitea).`);
1795
+ }
1796
+ function buildPrCreate(input) {
1797
+ return getForge(input.forge).prCreate(input);
1798
+ }
1799
+ function buildPrMerge(input) {
1800
+ return getForge(input.forge).prMerge(input);
1801
+ }
1802
+ function buildPrStatus(forge, ref) {
1803
+ return getForge(forge).prStatus(ref);
1804
+ }
1805
+ function buildIssueGet(forge, ref) {
1806
+ return getForge(forge).issueGet(ref);
1807
+ }
1808
+ function resolveForge(a2) {
1809
+ if (typeof a2.forge === "string" && a2.forge !== "") return a2.forge;
1810
+ if (typeof a2.remote === "string") return detectForge(a2.remote);
1811
+ throw new Error("provide a forge id (e.g. github, azure, or a registered adapter) or a remote URL to detect it");
1812
+ }
1813
+ var forgeParam = { type: "string", description: "Target forge id (github, azure, or a registered adapter). Omit to auto-detect from `remote`." };
1814
+ var remoteParam = { type: "string", description: "git remote URL \u2014 used to auto-detect the forge when `forge` is omitted." };
1815
+ var forgeTools = [
1816
+ {
1817
+ name: "forge.pr.create",
1818
+ description: "Open a pull request on GitHub (gh) or Azure DevOps (az). Gated via the DDISA grant cycle. Provider chosen by `forge` or auto-detected from `remote`.",
1819
+ parameters: {
1820
+ type: "object",
1821
+ properties: {
1822
+ forge: forgeParam,
1823
+ remote: remoteParam,
1824
+ title: { type: "string", description: "PR title." },
1825
+ body: { type: "string", description: "PR description / body." },
1826
+ head: { type: "string", description: "Source branch." },
1827
+ base: { type: "string", description: "Target branch. Omit for the repo default." }
1828
+ },
1829
+ required: ["title", "body", "head"]
1830
+ },
1831
+ execute: async (args) => {
1832
+ const a2 = args;
1833
+ const cmd = buildPrCreate({ forge: resolveForge(a2), title: a2.title, body: a2.body, head: a2.head, base: a2.base });
1834
+ return await runApeShell(cmd);
1835
+ }
1836
+ },
1837
+ {
1838
+ name: "forge.pr.merge",
1839
+ description: 'Merge a PR \u2014 or with auto=true, arm "merge when checks pass" (gh --auto / az auto-complete) so the platform merges only on green CI. Gated. Never bypasses required checks (branch protection is the server-side gate).',
1840
+ parameters: {
1841
+ type: "object",
1842
+ properties: {
1843
+ forge: forgeParam,
1844
+ remote: remoteParam,
1845
+ ref: { type: "string", description: "GitHub: PR number or branch. Azure: PR id." },
1846
+ auto: { type: "boolean", description: "Arm merge-when-green instead of immediate merge. Recommended." },
1847
+ squash: { type: "boolean", description: "Squash-merge. Default true." },
1848
+ delete_branch: { type: "boolean", description: "Delete the source branch after merge." }
1849
+ },
1850
+ required: ["ref"]
1851
+ },
1852
+ execute: async (args) => {
1853
+ const a2 = args;
1854
+ const cmd = buildPrMerge({ forge: resolveForge(a2), ref: a2.ref, auto: a2.auto, squash: a2.squash, deleteBranch: a2.delete_branch });
1855
+ return await runApeShell(cmd);
1856
+ }
1857
+ },
1858
+ {
1859
+ name: "forge.pr.status",
1860
+ description: "Fetch a PR's state + checks + review decision. Gated (read).",
1861
+ parameters: {
1862
+ type: "object",
1863
+ properties: { forge: forgeParam, remote: remoteParam, ref: { type: "string", description: "PR number/branch (GitHub) or id (Azure)." } },
1864
+ required: ["ref"]
1865
+ },
1866
+ execute: async (args) => {
1867
+ const a2 = args;
1868
+ return await runApeShell(buildPrStatus(resolveForge(a2), a2.ref));
1869
+ }
1870
+ },
1871
+ {
1872
+ name: "forge.issue.get",
1873
+ description: "Fetch an issue (GitHub) or work-item (Azure) \u2014 title, body, labels. Gated (read). Use to turn an assigned task into a coding run.",
1874
+ parameters: {
1875
+ type: "object",
1876
+ properties: { forge: forgeParam, remote: remoteParam, ref: { type: "string", description: "Issue number (GitHub) or work-item id (Azure)." } },
1877
+ required: ["ref"]
1878
+ },
1879
+ execute: async (args) => {
1880
+ const a2 = args;
1881
+ return await runApeShell(buildIssueGet(resolveForge(a2), a2.ref));
1882
+ }
1883
+ }
1884
+ ];
1885
+ function jailedRoot(envVar, fallbackName) {
1886
+ const home = homedir22();
1887
+ const raw = process2.env[envVar];
1888
+ const dir = raw ? resolve2(raw) : resolve2(home, fallbackName);
1889
+ if (dir !== home && !dir.startsWith(`${home}/`)) {
1890
+ throw new Error(`${envVar} (${dir}) must resolve inside the agent's home`);
1891
+ }
1892
+ return dir;
1893
+ }
1894
+ function workRoot() {
1895
+ return jailedRoot("OPENAPE_CODING_WORK_DIR", "work");
1896
+ }
1897
+ function reposRoot() {
1898
+ return jailedRoot("OPENAPE_CODING_REPOS_DIR", "repos");
1899
+ }
1900
+ var TASK_ID_RE = /^[\w.-]{1,64}$/;
1901
+ var BRANCH_RE2 = /^[\w./-]{1,128}$/;
1902
+ var URL_RE = /^(?:https:\/\/|git@)[\w@:/.-]{3,256}$/;
1903
+ function assertTaskId(v2) {
1904
+ if (typeof v2 !== "string" || !TASK_ID_RE.test(v2)) {
1905
+ throw new Error("task_id must match ^[a-zA-Z0-9._-]{1,64}$");
1906
+ }
1907
+ return v2;
1908
+ }
1909
+ function assertBranch2(v2) {
1910
+ if (typeof v2 !== "string" || !BRANCH_RE2.test(v2)) {
1911
+ throw new Error("branch must match ^[A-Za-z0-9._/-]{1,128}$");
1912
+ }
1913
+ return v2;
1914
+ }
1915
+ function resolveRepo(repo) {
1916
+ if (typeof repo !== "string" || repo === "") {
1917
+ throw new Error("repo must be a non-empty string (URL or path under $HOME)");
1918
+ }
1919
+ const home = homedir22();
1920
+ if (URL_RE.test(repo)) {
1921
+ const tail = repo.replace(/\.git$/, "").replace(/[/:]+$/, "");
1922
+ const parts = tail.split(/[/:]/).filter(Boolean).slice(-2);
1923
+ const base = parts.join("-").replace(/[^\w.-]/g, "");
1924
+ if (!base) throw new Error("could not derive a clone name from repo URL");
1925
+ return { source: repo, baseDir: resolve2(reposRoot(), base), isUrl: true };
1926
+ }
1927
+ const candidate = repo.startsWith("~/") ? resolve2(home, repo.slice(2)) : resolve2(home, repo);
1928
+ if (candidate !== home && !candidate.startsWith(`${home}/`)) {
1929
+ throw new Error(`repo path "${repo}" resolves outside the agent's home`);
1930
+ }
1931
+ return { source: candidate, baseDir: candidate, isUrl: false };
1932
+ }
1933
+ function worktreePathFor(taskId) {
1934
+ return resolve2(workRoot(), assertTaskId(taskId));
1935
+ }
1936
+ var q = (s2) => `'${s2}'`;
1937
+ function buildCreateCommand(repo, taskId, branch) {
1938
+ const id = assertTaskId(taskId);
1939
+ const br = assertBranch2(branch);
1940
+ const { source, baseDir, isUrl } = resolveRepo(repo);
1941
+ const wt = worktreePathFor(id);
1942
+ const clone = isUrl ? `if [ ! -d ${q(baseDir)}/.git ]; then git clone ${q(source)} ${q(baseDir)}; fi` : `test -d ${q(baseDir)}/.git`;
1943
+ return [
1944
+ `mkdir -p ${q(reposRoot())} ${q(workRoot())}`,
1945
+ clone,
1946
+ `git -C ${q(baseDir)} fetch --quiet || true`,
1947
+ `git -C ${q(baseDir)} worktree add -b ${q(br)} ${q(wt)}`,
1948
+ `echo ${q(wt)}`
1949
+ ].join(" && ");
1950
+ }
1951
+ function buildRemoveCommand(repo, taskId) {
1952
+ const id = assertTaskId(taskId);
1953
+ const { baseDir } = resolveRepo(repo);
1954
+ const wt = worktreePathFor(id);
1955
+ return `git -C ${q(baseDir)} worktree remove --force ${q(wt)} && git -C ${q(baseDir)} worktree prune`;
1956
+ }
1957
+ function buildListCommand() {
1958
+ return `ls -1 ${q(workRoot())} 2>/dev/null || true`;
1959
+ }
1960
+ var gitWorktreeTools = [
1961
+ {
1962
+ name: "git.worktree",
1963
+ description: "Manage isolated git worktrees for coding tasks. action=create clones the repo (cached under ~/repos) and adds a fresh worktree under ~/work/<task_id> on a new branch. action=remove tears it down. action=list shows current task worktrees. Git operations go through the DDISA grant cycle (git-shape).",
1964
+ parameters: {
1965
+ type: "object",
1966
+ properties: {
1967
+ action: { type: "string", enum: ["create", "remove", "list"], description: "create | remove | list" },
1968
+ repo: { type: "string", description: "For create/remove: git remote URL (https/git@) or a path under $HOME to an existing clone." },
1969
+ task_id: { type: "string", description: "For create/remove: identifier for the worktree, ^[a-zA-Z0-9._-]{1,64}$. The worktree lands at ~/work/<task_id>." },
1970
+ branch: { type: "string", description: "For create: new branch name, ^[A-Za-z0-9._/-]{1,128}$." }
1971
+ },
1972
+ required: ["action"]
1973
+ },
1974
+ execute: async (args) => {
1975
+ const a2 = args;
1976
+ let cmd;
1977
+ if (a2.action === "create") {
1978
+ if (typeof a2.branch !== "string") throw new Error("branch is required for action=create");
1979
+ cmd = buildCreateCommand(a2.repo, assertTaskId(a2.task_id), a2.branch);
1980
+ } else if (a2.action === "remove") {
1981
+ cmd = buildRemoveCommand(a2.repo, assertTaskId(a2.task_id));
1982
+ } else if (a2.action === "list") {
1983
+ cmd = buildListCommand();
1984
+ } else {
1985
+ throw new Error("action must be one of: create, remove, list");
1986
+ }
1987
+ const res = await runApeShell(cmd);
1988
+ return {
1989
+ action: a2.action,
1990
+ ...a2.action !== "list" ? { worktree: worktreePathFor(assertTaskId(a2.task_id)) } : {},
1991
+ stdout: res.stdout,
1992
+ stderr: res.stderr,
1993
+ exit_code: res.exit_code,
1994
+ ...res.error ? { error: res.error, hint: res.hint } : {}
1995
+ };
1996
+ }
1596
1997
  }
1597
1998
  ];
1598
1999
  var MAX_BYTES2 = 1024 * 1024;
@@ -1823,13 +2224,53 @@ var timeTools = [
1823
2224
  }
1824
2225
  }
1825
2226
  ];
2227
+ var CWD_RE = /^[\w./-]{1,256}$/;
2228
+ async function runVerify(cwd, command, timeoutMs) {
2229
+ if (typeof cwd !== "string" || !CWD_RE.test(cwd)) {
2230
+ throw new Error("cwd must match ^[A-Za-z0-9._/-]{1,256}$");
2231
+ }
2232
+ if (typeof command !== "string" || command.trim() === "") {
2233
+ throw new Error("verify command must be a non-empty string");
2234
+ }
2235
+ const res = await runApeShell(`cd '${cwd}' && ${command}`, timeoutMs);
2236
+ return {
2237
+ passed: res.exit_code === 0,
2238
+ exit_code: res.exit_code,
2239
+ stdout: res.stdout,
2240
+ stderr: res.stderr,
2241
+ ...res.timed_out ? { timed_out: true } : {}
2242
+ };
2243
+ }
2244
+ var verifyTools = [
2245
+ {
2246
+ name: "verify",
2247
+ description: "Run the verification command (tests/build/lint) in a worktree and report pass/fail. The coding loop must NOT open or merge a PR when this fails. Runs through the DDISA grant cycle (same as bash). Returns { passed, exit_code, stdout, stderr }.",
2248
+ parameters: {
2249
+ type: "object",
2250
+ properties: {
2251
+ cwd: { type: "string", description: "Worktree path to run in (e.g. ~/work/issue-42)." },
2252
+ command: { type: "string", description: "Verification command, e.g. `pnpm test` or `npm run build && npm test`." },
2253
+ timeout_ms: { type: "number", description: "Wall-clock cap incl. approval wait. Default 300000." }
2254
+ },
2255
+ required: ["cwd", "command"]
2256
+ },
2257
+ execute: async (args) => {
2258
+ const a2 = args;
2259
+ const timeout = typeof a2.timeout_ms === "number" && a2.timeout_ms > 0 ? a2.timeout_ms : void 0;
2260
+ return await runVerify(a2.cwd, a2.command, timeout);
2261
+ }
2262
+ }
2263
+ ];
1826
2264
  var ALL_TOOLS = [
1827
2265
  ...timeTools,
1828
2266
  ...httpTools,
1829
2267
  ...fileTools,
1830
2268
  ...tasksTools,
1831
2269
  ...mailTools,
1832
- ...bashTools
2270
+ ...bashTools,
2271
+ ...gitWorktreeTools,
2272
+ ...verifyTools,
2273
+ ...forgeTools
1833
2274
  ];
1834
2275
  var TOOLS = Object.fromEntries(
1835
2276
  ALL_TOOLS.map((t2) => [t2.name, t2])
@@ -3112,10 +3553,8 @@ var consola = createConsola2();
3112
3553
  // ../../packages/apes/dist/chunk-DYSFQ26B.js
3113
3554
  var import_shell_quote = __toESM(require_shell_quote(), 1);
3114
3555
 
3115
- // ../../node_modules/.pnpm/consola@3.4.2/node_modules/consola/dist/utils.mjs
3116
- import "tty";
3117
-
3118
- // ../../node_modules/.pnpm/citty@0.1.6/node_modules/citty/dist/index.mjs
3556
+ // ../../node_modules/.pnpm/citty@0.2.2/node_modules/citty/dist/index.mjs
3557
+ import { parseArgs as parseArgs$1 } from "util";
3119
3558
  function defineCommand(def) {
3120
3559
  return def;
3121
3560
  }
@@ -3832,7 +4271,7 @@ function shouldAutoAccept(peerEmail, identity, allowlist) {
3832
4271
  import { execFileSync as execFileSync3 } from "child_process";
3833
4272
  import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync } from "fs";
3834
4273
  import { homedir as homedir8 } from "os";
3835
- import { dirname as dirname2, join as join8, resolve as resolve2 } from "path";
4274
+ import { dirname as dirname2, join as join8, resolve as resolve3 } from "path";
3836
4275
  import { fileURLToPath } from "url";
3837
4276
  import { parse as parseYaml } from "yaml";
3838
4277
  var SKILLS_SUBDIR = [".openape", "agent", "skills"];
@@ -3941,7 +4380,7 @@ function scanSkillsDir(dir) {
3941
4380
  }
3942
4381
  function defaultSkillsDir() {
3943
4382
  const here = dirname2(fileURLToPath(import.meta.url));
3944
- return resolve2(here, "..", "default-skills");
4383
+ return resolve3(here, "..", "default-skills");
3945
4384
  }
3946
4385
  function composeSkills(home, enabledTools) {
3947
4386
  const enabled = new Set(enabledTools);
@@ -4005,7 +4444,7 @@ function readDefaultPersona() {
4005
4444
  if (_defaultPersonaCache !== void 0) return _defaultPersonaCache;
4006
4445
  try {
4007
4446
  const here = dirname2(fileURLToPath(import.meta.url));
4008
- const path = resolve2(here, "..", "default-persona.md");
4447
+ const path = resolve3(here, "..", "default-persona.md");
4009
4448
  if (!existsSync6(path)) {
4010
4449
  _defaultPersonaCache = null;
4011
4450
  return null;
@@ -4287,8 +4726,8 @@ function loadBridgeEnvFile() {
4287
4726
  const key = trimmed.slice(0, eq).trim();
4288
4727
  const value = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
4289
4728
  if (!key) continue;
4290
- if (process2.env[key] === void 0) {
4291
- process2.env[key] = value;
4729
+ if (process3.env[key] === void 0) {
4730
+ process3.env[key] = value;
4292
4731
  }
4293
4732
  }
4294
4733
  } catch {
@@ -4296,24 +4735,24 @@ function loadBridgeEnvFile() {
4296
4735
  }
4297
4736
  function readConfig() {
4298
4737
  loadBridgeEnvFile();
4299
- const toolsRaw = process2.env.APE_CHAT_BRIDGE_TOOLS ?? "";
4738
+ const toolsRaw = process3.env.APE_CHAT_BRIDGE_TOOLS ?? "";
4300
4739
  const tools = toolsRaw.split(",").map((s2) => s2.trim()).filter(Boolean);
4301
- const maxStepsRaw = process2.env.APE_CHAT_BRIDGE_MAX_STEPS;
4740
+ const maxStepsRaw = process3.env.APE_CHAT_BRIDGE_MAX_STEPS;
4302
4741
  const maxSteps = maxStepsRaw ? Number.parseInt(maxStepsRaw, 10) : DEFAULT_MAX_STEPS;
4303
- const model = process2.env.APE_CHAT_BRIDGE_MODEL;
4742
+ const model = process3.env.APE_CHAT_BRIDGE_MODEL;
4304
4743
  if (!model) {
4305
4744
  throw new Error(
4306
4745
  "APE_CHAT_BRIDGE_MODEL is not set. Set it in the bridge .env (usually `~/Library/Application Support/openape/bridge/.env` on macOS) or globally in `~/litellm/.env` so resolveBridgeConfig picks it up at spawn time. Common values: `gpt-5.4` (ChatGPT-only LiteLLM proxy), `claude-haiku-4-5` (Anthropic-only)."
4307
4746
  );
4308
4747
  }
4309
4748
  return {
4310
- endpoint: (process2.env.APE_CHAT_ENDPOINT ?? DEFAULT_ENDPOINT).replace(/\/$/, ""),
4311
- apesBin: process2.env.APE_CHAT_BRIDGE_APES_BIN ?? DEFAULT_APES_BIN,
4749
+ endpoint: (process3.env.APE_CHAT_ENDPOINT ?? DEFAULT_ENDPOINT).replace(/\/$/, ""),
4750
+ apesBin: process3.env.APE_CHAT_BRIDGE_APES_BIN ?? DEFAULT_APES_BIN,
4312
4751
  model,
4313
- systemPrompt: process2.env.APE_CHAT_BRIDGE_SYSTEM_PROMPT ?? DEFAULT_SYSTEM_PROMPT,
4752
+ systemPrompt: process3.env.APE_CHAT_BRIDGE_SYSTEM_PROMPT ?? DEFAULT_SYSTEM_PROMPT,
4314
4753
  tools,
4315
4754
  maxSteps: Number.isFinite(maxSteps) && maxSteps > 0 ? maxSteps : DEFAULT_MAX_STEPS,
4316
- roomFilter: process2.env.APE_CHAT_BRIDGE_ROOM
4755
+ roomFilter: process3.env.APE_CHAT_BRIDGE_ROOM
4317
4756
  };
4318
4757
  }
4319
4758
  async function getIdentity() {
@@ -4325,15 +4764,21 @@ async function getIdentity() {
4325
4764
  return { email: claims.sub };
4326
4765
  }
4327
4766
  function log(line) {
4328
- process2.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
4767
+ process3.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
4329
4768
  `);
4330
4769
  }
4331
4770
  function sleep(ms) {
4332
- return new Promise((resolve3) => setTimeout(resolve3, ms));
4771
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
4333
4772
  }
4334
4773
  function truncate(s2, n2) {
4335
4774
  return s2.length <= n2 ? s2 : `${s2.slice(0, n2 - 1)}\u2026`;
4336
4775
  }
4776
+ function refusalText(reason) {
4777
+ const base = "I won't process this message \u2014 it looks like a prompt-injection attempt.";
4778
+ return reason ? `${base}
4779
+
4780
+ (matched: ${reason})` : base;
4781
+ }
4337
4782
  var Bridge = class {
4338
4783
  constructor(cfg, selfEmail, ownerEmail) {
4339
4784
  this.cfg = cfg;
@@ -4349,7 +4794,7 @@ var Bridge = class {
4349
4794
  chat: this.chat,
4350
4795
  ownerEmail: this.ownerEmail,
4351
4796
  log,
4352
- troopUrl: (process2.env.OPENAPE_TROOP_URL ?? "https://troop.openape.ai").replace(/\/$/, ""),
4797
+ troopUrl: (process3.env.OPENAPE_TROOP_URL ?? "https://troop.openape.ai").replace(/\/$/, ""),
4353
4798
  bearer: this.bearer
4354
4799
  });
4355
4800
  this.cron.start();
@@ -4364,14 +4809,18 @@ var Bridge = class {
4364
4809
  chat;
4365
4810
  bearer;
4366
4811
  cron;
4812
+ // Prompt-injection gate (#277). Pure heuristic by default — pluggable
4813
+ // backend later. The bridge is the choke-point for every chat message
4814
+ // before it reaches the agent runtime, so this is the right place.
4815
+ injectionDetector = createHeuristicDetector();
4367
4816
  /**
4368
4817
  * RuntimeConfig is shared across thread sessions and the cron runner.
4369
4818
  * The bridge resolves it from its own env at boot and reuses for the
4370
4819
  * whole process lifetime.
4371
4820
  */
4372
4821
  runtimeConfig() {
4373
- const apiBase = (process2.env.LITELLM_BASE_URL ?? "http://127.0.0.1:4000/v1").replace(/\/$/, "");
4374
- const apiKey = process2.env.LITELLM_API_KEY ?? process2.env.LITELLM_MASTER_KEY ?? "";
4822
+ const apiBase = (process3.env.LITELLM_BASE_URL ?? "http://127.0.0.1:4000/v1").replace(/\/$/, "");
4823
+ const apiKey = process3.env.LITELLM_API_KEY ?? process3.env.LITELLM_MASTER_KEY ?? "";
4375
4824
  if (!apiKey) {
4376
4825
  throw new Error("LITELLM_API_KEY (or LITELLM_MASTER_KEY) must be set in the bridge env.");
4377
4826
  }
@@ -4414,7 +4863,7 @@ var Bridge = class {
4414
4863
  if (accepted.length > 0) log(`accepted: ${accepted.join(", ")}`);
4415
4864
  if (skipped.length > 0) log(`skipped (not on allowlist): ${skipped.join(", ")}`);
4416
4865
  }
4417
- handleInbound(msg) {
4866
+ async handleInbound(msg) {
4418
4867
  if (msg.senderEmail === this.selfEmail) return;
4419
4868
  if (!msg.body.trim()) return;
4420
4869
  if (this.cfg.roomFilter && msg.roomId !== this.cfg.roomFilter) return;
@@ -4423,6 +4872,26 @@ var Bridge = class {
4423
4872
  return;
4424
4873
  }
4425
4874
  log(`[${msg.roomId}/${msg.threadId.slice(0, 8)}] in: ${truncate(msg.body, 80)}`);
4875
+ const decision = await decide(this.injectionDetector, {
4876
+ text: msg.body,
4877
+ sender: {
4878
+ email: msg.senderEmail,
4879
+ isOwner: msg.senderEmail === this.ownerEmail
4880
+ }
4881
+ });
4882
+ if (decision.blocked) {
4883
+ log(`[${msg.roomId}/${msg.threadId.slice(0, 8)}] BLOCKED prompt-injection (score=${decision.score.toFixed(2)}, reason=${decision.reason ?? "n/a"})`);
4884
+ try {
4885
+ await this.chat.postMessage(msg.roomId, refusalText(decision.reason), {
4886
+ replyTo: msg.id,
4887
+ threadId: msg.threadId
4888
+ });
4889
+ } catch (err) {
4890
+ const m2 = err instanceof Error ? err.message : String(err);
4891
+ log(`[${msg.roomId}] failed to post refusal: ${m2}`);
4892
+ }
4893
+ return;
4894
+ }
4426
4895
  const session = this.getOrCreateThread(msg.roomId, msg.threadId);
4427
4896
  session.enqueue(msg.body, msg.id);
4428
4897
  }
@@ -4461,7 +4930,7 @@ var Bridge = class {
4461
4930
  const bearer = await this.bearer();
4462
4931
  const wsUrl = `${this.cfg.endpoint.replace(/^http/, "ws")}/api/ws?token=${encodeURIComponent(bearer.replace(/^Bearer\s+/i, ""))}`;
4463
4932
  const ws = new WebSocket(wsUrl);
4464
- return new Promise((resolve3, reject) => {
4933
+ return new Promise((resolve4, reject) => {
4465
4934
  let pingTimer;
4466
4935
  let allowlistTimer;
4467
4936
  ws.on("open", () => {
@@ -4489,12 +4958,12 @@ var Bridge = class {
4489
4958
  return;
4490
4959
  }
4491
4960
  if (frame.type !== "message") return;
4492
- this.handleInbound(frame.payload);
4961
+ void this.handleInbound(frame.payload);
4493
4962
  });
4494
4963
  ws.on("close", () => {
4495
4964
  if (pingTimer) clearInterval(pingTimer);
4496
4965
  if (allowlistTimer) clearInterval(allowlistTimer);
4497
- resolve3();
4966
+ resolve4();
4498
4967
  });
4499
4968
  ws.on("error", (err) => {
4500
4969
  if (pingTimer) clearInterval(pingTimer);
@@ -4533,7 +5002,7 @@ async function main() {
4533
5002
  }
4534
5003
  main().catch((err) => {
4535
5004
  const msg = err instanceof Error ? err.message : String(err);
4536
- process2.stderr.write(`fatal: ${msg}
5005
+ process3.stderr.write(`fatal: ${msg}
4537
5006
  `);
4538
- process2.exit(1);
5007
+ process3.exit(1);
4539
5008
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openape/ape-agent",
3
- "version": "2.6.3",
3
+ "version": "2.7.1",
4
4
  "description": "OpenApe agent runtime: per-agent process that connects to chat.openape.ai, runs the LLM loop with tools + cron tasks, and streams replies back to owners.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,17 +23,18 @@
23
23
  "ofetch": "^1.4.1",
24
24
  "ws": "^8.18.0",
25
25
  "yaml": "^2.8.0",
26
- "@openape/apes": "1.25.0",
27
- "@openape/cli-auth": "0.4.0"
26
+ "@openape/apes": "1.26.0",
27
+ "@openape/cli-auth": "0.4.1",
28
+ "@openape/prompt-injection-detector": "0.1.0"
28
29
  },
29
30
  "devDependencies": {
30
31
  "@antfu/eslint-config": "^7.6.1",
31
32
  "@types/node": "^22.19.13",
32
33
  "@types/ws": "^8.5.13",
33
- "eslint": "^9.35.0",
34
+ "eslint": "^10.4.0",
34
35
  "tsup": "^8.5.1",
35
36
  "typescript": "^5.9.3",
36
- "vitest": "^3.2.4"
37
+ "vitest": "^4.1.7"
37
38
  },
38
39
  "engines": {
39
40
  "node": ">=22"