viveworker 0.8.8 → 0.8.10

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.
@@ -36,6 +36,7 @@ import {
36
36
  pickInboxReplyCandidate,
37
37
  reconcileInboxDraftMarkers,
38
38
  getMoltbookReplyQuotaState,
39
+ loopbackFetch,
39
40
  } from "./moltbook-api.mjs";
40
41
 
41
42
  function fail(message, code = 1) {
@@ -58,10 +59,8 @@ async function resolveOnBridge(env, commentId) {
58
59
  const base = (env.VIVEWORKER_BASE_URL || "https://127.0.0.1:8810").replace(/\/+$/u, "");
59
60
  const secret = env.VIVEWORKER_HOOK_SECRET || "";
60
61
  if (!secret) return;
61
- const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
62
- if (!prev) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
63
62
  try {
64
- await fetch(`${base}/api/providers/moltbook/events`, {
63
+ await loopbackFetch(`${base}/api/providers/moltbook/events`, {
65
64
  method: "POST",
66
65
  headers: {
67
66
  "content-type": "application/json",
@@ -71,8 +70,6 @@ async function resolveOnBridge(env, commentId) {
71
70
  });
72
71
  } catch {
73
72
  // ignore
74
- } finally {
75
- if (!prev) process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev ?? "";
76
73
  }
77
74
  }
78
75
 
@@ -596,14 +593,12 @@ async function cmdPropose(postId, flags) {
596
593
  const secret = env.VIVEWORKER_HOOK_SECRET || "";
597
594
  if (!secret) fail("VIVEWORKER_HOOK_SECRET missing (expected in ~/.viveworker/moltbook.env)");
598
595
 
599
- const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
600
- if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
601
596
 
602
597
  // Submit draft to bridge. The bridge persists it to disk and handles posting
603
598
  // on approval — the CLI exits immediately (fire-and-forget).
604
599
  let submitRes;
605
600
  try {
606
- const r = await fetch(`${base}/api/providers/moltbook/draft`, {
601
+ const r = await loopbackFetch(`${base}/api/providers/moltbook/draft`, {
607
602
  method: "POST",
608
603
  headers: { "content-type": "application/json", "x-viveworker-hook-secret": secret },
609
604
  body: JSON.stringify({
@@ -643,7 +638,6 @@ async function cmdPropose(postId, flags) {
643
638
  }
644
639
 
645
640
  console.log(JSON.stringify({ ok: true, token, ttlSec, fireAndForget: true }));
646
- if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls ?? "";
647
641
  }
648
642
 
649
643
  async function cmdMarkScoutSeen(postId) {
@@ -907,8 +901,6 @@ async function cmdCompose(flags) {
907
901
  const secret = env.VIVEWORKER_HOOK_SECRET || "";
908
902
  if (!secret) fail("VIVEWORKER_HOOK_SECRET missing");
909
903
 
910
- const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
911
- if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
912
904
 
913
905
  const state = rollScoutDayIfNeeded(await readScoutState());
914
906
  const maxCompose = Number(flags["max-daily"]) || 3;
@@ -942,7 +934,7 @@ async function cmdCompose(flags) {
942
934
  // Fetch activity summary from bridge.
943
935
  let summary;
944
936
  try {
945
- const r = await fetch(`${base}/api/providers/moltbook/activity-summary?${params}`, {
937
+ const r = await loopbackFetch(`${base}/api/providers/moltbook/activity-summary?${params}`, {
946
938
  headers: { "x-viveworker-hook-secret": secret },
947
939
  });
948
940
  if (!r.ok) fail(`activity-summary: ${r.status}`);
@@ -978,7 +970,6 @@ async function cmdCompose(flags) {
978
970
  maxComposeDaily: maxCompose,
979
971
  }));
980
972
 
981
- if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls ?? "";
982
973
  }
983
974
 
984
975
  async function cmdComposePropose(flags) {
@@ -996,8 +987,6 @@ async function cmdComposePropose(flags) {
996
987
  const secret = env.VIVEWORKER_HOOK_SECRET || "";
997
988
  if (!secret) fail("VIVEWORKER_HOOK_SECRET missing");
998
989
 
999
- const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1000
- if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
1001
990
 
1002
991
  const sourceId = `compose:${Date.now()}`;
1003
992
 
@@ -1005,7 +994,7 @@ async function cmdComposePropose(flags) {
1005
994
  // on approval — the CLI exits immediately (fire-and-forget).
1006
995
  let submitRes;
1007
996
  try {
1008
- const r = await fetch(`${base}/api/providers/moltbook/draft`, {
997
+ const r = await loopbackFetch(`${base}/api/providers/moltbook/draft`, {
1009
998
  method: "POST",
1010
999
  headers: { "content-type": "application/json", "x-viveworker-hook-secret": secret },
1011
1000
  body: JSON.stringify({
@@ -1033,7 +1022,6 @@ async function cmdComposePropose(flags) {
1033
1022
  if (!token) fail(`bridge did not return a token: ${JSON.stringify(submitRes)}`);
1034
1023
 
1035
1024
  console.log(JSON.stringify({ ok: true, token, ttlSec, fireAndForget: true }));
1036
- if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls ?? "";
1037
1025
  }
1038
1026
 
1039
1027
  export async function runMoltbookCli(argv) {
@@ -17,14 +17,8 @@
17
17
  import http from "node:http";
18
18
  import process from "node:process";
19
19
 
20
- // The bridge usually listens on HTTPS with a self-signed certificate. Since
21
- // we only ever talk to it on loopback, disable TLS verification for outgoing
22
- // fetch calls from this process. Scoped to the watcher process only.
23
- if (!process.env.NODE_TLS_REJECT_UNAUTHORIZED) {
24
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
25
- }
26
-
27
20
  import {
21
+ loopbackFetch,
28
22
  createMoltbookClient,
29
23
  extractNotifications,
30
24
  isCommentNotification,
@@ -81,7 +75,7 @@ async function pushToBridge(item) {
81
75
  const controller = new AbortController();
82
76
  const timer = setTimeout(() => controller.abort(), 10_000);
83
77
  try {
84
- const res = await fetch(`${VIVEWORKER_BASE}/api/providers/moltbook/events`, {
78
+ const res = await loopbackFetch(`${VIVEWORKER_BASE}/api/providers/moltbook/events`, {
85
79
  method: "POST",
86
80
  headers: {
87
81
  "content-type": "application/json",
@@ -1007,9 +1007,28 @@ function selectSupportedPaymentRequirement(x402, flags = {}) {
1007
1007
 
1008
1008
  function summarizeRequirement(requirement) {
1009
1009
  const network = SUPPORTED_BUYER_NETWORKS[String(requirement.network)];
1010
- const assetKey = String(requirement.extra?.asset || "").toLowerCase();
1011
- const decimals = Number(requirement.extra?.decimals ?? (String(requirement.scheme || "") === LIQUID_X402_SCHEME ? 8 : 6));
1012
- const assetLabel = PAYMENT_ASSETS[assetKey]?.label || (String(requirement.scheme || "") === LIQUID_X402_SCHEME ? "USDt" : "USDC");
1010
+ const isLiquid = String(requirement.scheme || "") === LIQUID_X402_SCHEME;
1011
+ // SECURITY: never trust the seller-supplied requirement.extra.decimals or the
1012
+ // free-form asset label for the amount shown to / approved by the human.
1013
+ // Resolve the asset against the network's known assets and take decimals +
1014
+ // label from the trusted PAYMENT_ASSETS table, so the displayed/approved
1015
+ // amount always matches what is actually signed (the atomic maxAmountRequired).
1016
+ // Otherwise a hostile seller could advertise inflated decimals to make the
1017
+ // phone show a tiny amount while a much larger value is authorized.
1018
+ const claimedAssetKey = String(requirement.extra?.asset || "").toLowerCase();
1019
+ const resolvedAsset =
1020
+ normalizePaymentAsset(claimedAssetKey, String(requirement.network)) || (isLiquid ? "usdt" : "usdc");
1021
+ const trustedAsset = PAYMENT_ASSETS[resolvedAsset] || PAYMENT_ASSETS[isLiquid ? "usdt" : "usdc"];
1022
+ const decimals = trustedAsset.decimals;
1023
+ const assetLabel = trustedAsset.label;
1024
+ // Refuse if the seller advertised decimals that disagree with the known token
1025
+ // decimals — the 402 is malformed or is attempting an amount-display spoof.
1026
+ const advertisedDecimals = requirement.extra?.decimals;
1027
+ if (advertisedDecimals != null && Number(advertisedDecimals) !== decimals) {
1028
+ throw new Error(
1029
+ `x402 requirement decimals (${advertisedDecimals}) do not match the known ${assetLabel} decimals (${decimals}) on ${requirement.network}; refusing to sign a possibly spoofed amount.`
1030
+ );
1031
+ }
1013
1032
  return {
1014
1033
  network: String(requirement.network),
1015
1034
  chainId: network?.chainId ?? null,
@@ -1019,8 +1038,11 @@ function summarizeRequirement(requirement) {
1019
1038
  amountUsdc: formatAtomicAmount(requirement.maxAmountRequired, decimals),
1020
1039
  assetLabel,
1021
1040
  payTo: String(requirement.payTo),
1041
+ // The on-chain token contract that will actually be signed against. Surfaced
1042
+ // (printPaymentSummary / phone approval payload) so a seller cannot label a
1043
+ // payment "USDC" while pointing requirement.asset at a different token.
1022
1044
  asset: String(requirement.asset),
1023
- assetKey,
1045
+ assetKey: resolvedAsset,
1024
1046
  resource: String(requirement.resource || ""),
1025
1047
  description: String(requirement.description || ""),
1026
1048
  };
@@ -1031,6 +1053,7 @@ function printPaymentSummary(summary) {
1031
1053
  console.log("");
1032
1054
  console.log(`${summary.paid ? "Paid" : "Payment required"} — ${summary.amount} ${summary.assetLabel} on ${network?.label || summary.network}`);
1033
1055
  console.log(` to: ${summary.payTo}`);
1056
+ if (summary.asset) console.log(` token contract: ${summary.asset}`);
1034
1057
  if (summary.payer) console.log(` from: ${summary.payer}`);
1035
1058
  if (summary.resource) console.log(` resource: ${summary.resource}`);
1036
1059
  console.log("");
@@ -4,7 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import crypto from "node:crypto";
5
5
  import { createServer as createHttpServer } from "node:http";
6
6
  import { createServer as createHttpsServer } from "node:https";
7
- import { promises as fs, readFileSync, createReadStream, watch as watchFs } from "node:fs";
7
+ import { promises as fs, readFileSync, realpathSync, createReadStream, watch as watchFs } from "node:fs";
8
8
  import net from "node:net";
9
9
  import os from "node:os";
10
10
  import path from "node:path";
@@ -231,7 +231,7 @@ async function flushPendingStateWrite(config, state) {
231
231
  const cli = parseCliArgs(process.argv.slice(2));
232
232
  const envFile = resolveEnvFile(cli.envFile);
233
233
  loadEnvFile(envFile);
234
- loadEnvFile(path.join(os.homedir(), ".viveworker", "a2a.env"));
234
+ loadEnvFile(path.join(os.homedir(), ".viveworker", "a2a.env"), { allowKeys: (key) => key.startsWith("A2A_") });
235
235
  loadEnvFile(path.join(os.homedir(), ".viveworker", "remote-pairing.env"));
236
236
  await maybeRotateStartupPairingEnv(envFile);
237
237
 
@@ -1233,6 +1233,79 @@ function isSensitiveCommandPath(candidatePath) {
1233
1233
  return false;
1234
1234
  }
1235
1235
 
1236
+ // Confine a stored timeline image path to the dirs the bridge itself writes
1237
+ // attachments into. Timeline entries can be created by SESSION_SECRET-authed
1238
+ // ingestion paths (provider/timeline events) that set entry.imagePaths to an
1239
+ // arbitrary absolute path; the per-path HMAC is signed by the bridge over that
1240
+ // same path, so it is NOT a confinement control. The /api/timeline image route
1241
+ // fs.readFile()s whatever this resolves to, so confinement MUST happen here.
1242
+ // Returns the original path string on success; fails closed ("") for anything
1243
+ // outside the roots, non-existent files, symlink escapes, sensitive names, or
1244
+ // missing config.
1245
+ function resolveConfinedTimelineImagePath(config, candidatePath) {
1246
+ const normalized = cleanText(candidatePath || "");
1247
+ if (!normalized || !path.isAbsolute(normalized)) {
1248
+ return "";
1249
+ }
1250
+ if (isSensitiveCommandPath(normalized)) {
1251
+ return "";
1252
+ }
1253
+ const roots = [config?.timelineAttachmentsDir, config?.replyUploadsDir]
1254
+ .map((root) => cleanText(root || ""))
1255
+ .filter(Boolean);
1256
+ if (roots.length === 0) {
1257
+ return "";
1258
+ }
1259
+ let canonicalTarget;
1260
+ try {
1261
+ canonicalTarget = realpathSync(normalized);
1262
+ } catch {
1263
+ return "";
1264
+ }
1265
+ for (const root of roots) {
1266
+ let canonicalRoot;
1267
+ try {
1268
+ canonicalRoot = realpathSync(root);
1269
+ } catch {
1270
+ continue;
1271
+ }
1272
+ if (isPathWithinRoot(canonicalRoot, canonicalTarget)) {
1273
+ return normalized;
1274
+ }
1275
+ }
1276
+ return "";
1277
+ }
1278
+
1279
+ function realpathNearestExistingAncestor(resolvedPath) {
1280
+ // Walk up from resolvedPath to the nearest ancestor that exists on disk,
1281
+ // realpath that ancestor (following any symlinks), and re-attach the
1282
+ // not-yet-existing trailing segments. Returns the canonicalized full path, or
1283
+ // null if no ancestor can be canonicalized. This lets a non-existent
1284
+ // write/read target still be symlink-checked via its existing parent dir.
1285
+ const normalized = cleanText(resolvedPath || "");
1286
+ if (!normalized) {
1287
+ return null;
1288
+ }
1289
+ const trailing = [];
1290
+ let current = normalized;
1291
+ for (let depth = 0; depth < 4096; depth += 1) {
1292
+ let canonicalAncestor;
1293
+ try {
1294
+ canonicalAncestor = realpathSync(current);
1295
+ } catch {
1296
+ const parent = path.dirname(current);
1297
+ if (parent === current) {
1298
+ return null;
1299
+ }
1300
+ trailing.unshift(path.basename(current));
1301
+ current = parent;
1302
+ continue;
1303
+ }
1304
+ return trailing.length === 0 ? canonicalAncestor : path.join(canonicalAncestor, ...trailing);
1305
+ }
1306
+ return null;
1307
+ }
1308
+
1236
1309
  function resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }) {
1237
1310
  const rawToken = cleanText(token || "");
1238
1311
  const normalizedCwd = cleanText(cwd || "");
@@ -1243,11 +1316,57 @@ function resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }) {
1243
1316
  if (rawToken === "-" || rawToken.startsWith("~") || rawToken.startsWith("http://") || rawToken.startsWith("https://")) {
1244
1317
  return null;
1245
1318
  }
1319
+ // Reject shell-active metacharacters in a path argument. The classifier
1320
+ // resolves the token lexically (path.resolve does NOT expand them), but the
1321
+ // agent later runs the ORIGINAL command through a shell that WOULD expand
1322
+ // globs (* ? [ ]), brace lists ({a,b}) and variables ($VAR / ${VAR}) — reading
1323
+ // files the classifier never vetted (e.g. "cat *.env", "cat {x,/etc/passwd}").
1324
+ if (/[*?[\]{}$]/.test(rawToken)) {
1325
+ return null;
1326
+ }
1246
1327
  const resolved = path.resolve(normalizedCwd, rawToken);
1247
- if (!isPathWithinRoot(normalizedWorkspaceRoot, resolved)) {
1328
+ // Canonicalize via realpath so an in-workspace symlink cannot redirect the
1329
+ // read at an existing file outside the root or at a sensitive path. Root and
1330
+ // target are both realpath'd so platform symlinks (e.g. macOS /var ->
1331
+ // /private/var) do not cause false negatives.
1332
+ let canonicalTarget;
1333
+ try {
1334
+ canonicalTarget = realpathSync(resolved);
1335
+ } catch {
1336
+ // The target does not exist yet (e.g. a flag value like the "5" in
1337
+ // `head -n 5` mis-parsed as a path, or a not-yet-created WRITE target). The
1338
+ // target's PARENT may still be an in-workspace symlink that redirects the
1339
+ // write/read outside the root (e.g. "notes -> /etc" with "notes/readme.md").
1340
+ // Canonicalize via the nearest existing ancestor and re-check confinement
1341
+ // against the realpath'd root. Fail closed if nothing can be canonicalized.
1342
+ const canonicalNonExistent = realpathNearestExistingAncestor(resolved);
1343
+ if (!canonicalNonExistent) {
1344
+ return null;
1345
+ }
1346
+ let canonicalRootForMissing;
1347
+ try {
1348
+ canonicalRootForMissing = realpathSync(normalizedWorkspaceRoot);
1349
+ } catch {
1350
+ return null;
1351
+ }
1352
+ if (!isPathWithinRoot(canonicalRootForMissing, canonicalNonExistent)) {
1353
+ return null;
1354
+ }
1355
+ if (isSensitiveCommandPath(canonicalNonExistent)) {
1356
+ return null;
1357
+ }
1358
+ return resolved;
1359
+ }
1360
+ let canonicalRoot;
1361
+ try {
1362
+ canonicalRoot = realpathSync(normalizedWorkspaceRoot);
1363
+ } catch {
1248
1364
  return null;
1249
1365
  }
1250
- if (isSensitiveCommandPath(resolved)) {
1366
+ if (!isPathWithinRoot(canonicalRoot, canonicalTarget)) {
1367
+ return null;
1368
+ }
1369
+ if (isSensitiveCommandPath(canonicalTarget)) {
1251
1370
  return null;
1252
1371
  }
1253
1372
  return resolved;
@@ -1592,6 +1711,29 @@ function normalizeTrustedReadTokens(tokens) {
1592
1711
  return stages[0];
1593
1712
  }
1594
1713
 
1714
+ function commandHasDangerousReadFlag(tokens) {
1715
+ // Read-tool options that execute external commands or read arbitrary files.
1716
+ const longDanger = ["--pre", "--pre-glob", "--hostname-bin", "--search-zip", "--file", "--config", "--ext-diff", "--textconv"];
1717
+ for (const token of tokens) {
1718
+ const value = String(token || "");
1719
+ if (!value.startsWith("-")) {
1720
+ continue;
1721
+ }
1722
+ if (longDanger.some((flag) => value === flag || value.startsWith(`${flag}=`))) {
1723
+ return true;
1724
+ }
1725
+ // Single-dash short clusters carrying a value-taking dangerous flag:
1726
+ // ripgrep -f (read a patterns file) and -z (run decompressors). A value-
1727
+ // taking short flag must be last in its cluster, so any single-dash token
1728
+ // containing lowercase f or z is rejected. Uppercase -F/-Z and long options
1729
+ // (--fixed-strings etc.) are unaffected.
1730
+ if (!value.startsWith("--") && /[fz]/.test(value)) {
1731
+ return true;
1732
+ }
1733
+ }
1734
+ return false;
1735
+ }
1736
+
1595
1737
  function evaluateTrustedReadCommandPolicy({ commandText, cwd, workspaceRoot }) {
1596
1738
  const normalizedCwd = cleanText(cwd || "");
1597
1739
  const normalizedWorkspaceRoot = cleanText(workspaceRoot || normalizedCwd);
@@ -1618,6 +1760,16 @@ function evaluateTrustedReadCommandPolicy({ commandText, cwd, workspaceRoot }) {
1618
1760
  return null;
1619
1761
  }
1620
1762
 
1763
+ // Reject read-tool flags that execute external commands or read arbitrary
1764
+ // files (ripgrep --pre= / --hostname-bin= / -f patternfile / -z search-zip,
1765
+ // git --ext-diff / --textconv, ...). The per-command branches below skip
1766
+ // "-"-prefixed tokens without inspecting their values, so without this guard
1767
+ // `rg --pre=/tmp/evil foo .` would auto-approve and ripgrep would execute the
1768
+ // attacker-named command.
1769
+ if (commandHasDangerousReadFlag(tokens)) {
1770
+ return null;
1771
+ }
1772
+
1621
1773
  const command = commandBaseName(tokens[0]);
1622
1774
  if (!command || /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(command)) {
1623
1775
  return null;
@@ -1651,11 +1803,6 @@ function unwrapShellCommand(commandText) {
1651
1803
  .trim();
1652
1804
  }
1653
1805
 
1654
- function extractCommandLineFromFunctionOutput(outputText) {
1655
- const match = String(outputText || "").match(/^Command:\s+(.+)$/mu);
1656
- return unwrapShellCommand(match?.[1] || "");
1657
- }
1658
-
1659
1806
  function parseToolArgumentsJson(value) {
1660
1807
  if (isPlainObject(value)) {
1661
1808
  return value;
@@ -1712,16 +1859,21 @@ function redactTimelineCommandText(commandText) {
1712
1859
  .replace(/\b([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*=)(["']?)[^\s"']+\2/giu, "$1[redacted]");
1713
1860
  }
1714
1861
 
1862
+ function escapeMarkdownCodeFenceBody(text) {
1863
+ return cleanText(text || "").replace(/```/gu, "\\`\\`\\`");
1864
+ }
1865
+
1715
1866
  function timelineCommandMessage(locale, { commandText = "", toolName = "", fileRefs = [] } = {}) {
1867
+ const safeCommandText = escapeMarkdownCodeFenceBody(redactTimelineCommandText(commandText));
1716
1868
  const commandBlock = commandText
1717
- ? `${t(locale, "server.message.commandLabel")}\n\`\`\`sh\n${redactTimelineCommandText(commandText)}\n\`\`\``
1869
+ ? `${t(locale, "server.message.commandLabel")}\n\`\`\`sh\n${safeCommandText}\n\`\`\``
1718
1870
  : "";
1719
1871
  const toolBlock = !commandBlock && toolName
1720
- ? `${t(locale, "server.message.toolLabel")}\n\`\`\`text\n${cleanText(toolName)}\n\`\`\``
1872
+ ? `${t(locale, "server.message.toolLabel")}\n\`\`\`text\n${escapeMarkdownCodeFenceBody(toolName)}\n\`\`\``
1721
1873
  : "";
1722
1874
  const files = normalizeTimelineFileRefs(fileRefs);
1723
1875
  const filesBlock = files.length
1724
- ? `${t(locale, "server.message.filesLabel")}\n\`\`\`text\n${files.join("\n")}\n\`\`\``
1876
+ ? `${t(locale, "server.message.filesLabel")}\n\`\`\`text\n${files.map(escapeMarkdownCodeFenceBody).join("\n")}\n\`\`\``
1725
1877
  : "";
1726
1878
  return [commandBlock || toolBlock, filesBlock].filter(Boolean).join("\n\n");
1727
1879
  }
@@ -1851,7 +2003,7 @@ function sedCommandPathArgs(tokens) {
1851
2003
  function autoPilotApprovalMessage(locale, commandText) {
1852
2004
  const prefix = t(locale, "server.message.autoPilotTrustedReadApproved");
1853
2005
  const commandBlock = cleanText(commandText || "")
1854
- ? `${t(locale, "server.message.commandLabel")}\n\`\`\`sh\n${cleanText(commandText || "")}\n\`\`\``
2006
+ ? `${t(locale, "server.message.commandLabel")}\n\`\`\`sh\n${escapeMarkdownCodeFenceBody(commandText)}\n\`\`\``
1855
2007
  : "";
1856
2008
  return [prefix, commandBlock].filter(Boolean).join("\n\n");
1857
2009
  }
@@ -4324,6 +4476,88 @@ function buildPushPayload({ config, kind, token, stableId, title, body, tab = ""
4324
4476
  };
4325
4477
  }
4326
4478
 
4479
+ const ALLOWED_PUSH_HOSTS = new Set([
4480
+ "fcm.googleapis.com",
4481
+ "android.googleapis.com",
4482
+ "updates.push.services.mozilla.com",
4483
+ ]);
4484
+ const ALLOWED_PUSH_HOST_SUFFIXES = [
4485
+ ".push.apple.com",
4486
+ ".notify.windows.com",
4487
+ ".push.services.mozilla.com",
4488
+ ];
4489
+
4490
+ function isBlockedPushIpLiteral(host) {
4491
+ const family = net.isIP(host);
4492
+ if (family === 0) {
4493
+ return false;
4494
+ }
4495
+ if (family === 6) {
4496
+ const lower = host.toLowerCase();
4497
+ if (lower === "::1" || lower === "::") {
4498
+ return true;
4499
+ }
4500
+ const mapped = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/u);
4501
+ if (mapped) {
4502
+ return isBlockedPushIpLiteral(mapped[1]);
4503
+ }
4504
+ const head = parseInt(lower.split(":")[0] || "0", 16);
4505
+ if ((head & 0xfe00) === 0xfc00) {
4506
+ return true;
4507
+ }
4508
+ if ((head & 0xffc0) === 0xfe80) {
4509
+ return true;
4510
+ }
4511
+ return false;
4512
+ }
4513
+ const parts = host.split(".").map((p) => Number(p));
4514
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
4515
+ return true;
4516
+ }
4517
+ const [a, b] = parts;
4518
+ if (a === 0 || a === 127) return true;
4519
+ if (a === 10) return true;
4520
+ if (a === 172 && b >= 16 && b <= 31) return true;
4521
+ if (a === 192 && b === 168) return true;
4522
+ if (a === 169 && b === 254) return true;
4523
+ if (a === 100 && b >= 64 && b <= 127) return true;
4524
+ if (a === 192 && b === 0) return true;
4525
+ if (a === 198 && (b === 18 || b === 19)) return true;
4526
+ return false;
4527
+ }
4528
+
4529
+ // Validate a web-push subscription endpoint before persisting it. The endpoint
4530
+ // is attacker-controllable by any session-holder; deliverWebPushItem POSTs every
4531
+ // (encrypted) notification to it, so an unrestricted endpoint is both a
4532
+ // notification-exfiltration channel and an SSRF primitive. Require https, reject
4533
+ // embedded credentials and private/loopback/link-local IP literals, and
4534
+ // allow-list the known public push provider hosts.
4535
+ function isAllowedPushEndpoint(endpoint) {
4536
+ let parsed;
4537
+ try {
4538
+ parsed = new URL(String(endpoint || ""));
4539
+ } catch {
4540
+ return false;
4541
+ }
4542
+ if (parsed.protocol !== "https:") {
4543
+ return false;
4544
+ }
4545
+ if (parsed.username || parsed.password) {
4546
+ return false;
4547
+ }
4548
+ const host = parsed.hostname.toLowerCase().replace(/^\[/u, "").replace(/\]$/u, "");
4549
+ if (!host) {
4550
+ return false;
4551
+ }
4552
+ if (isBlockedPushIpLiteral(host)) {
4553
+ return false;
4554
+ }
4555
+ if (ALLOWED_PUSH_HOSTS.has(host)) {
4556
+ return true;
4557
+ }
4558
+ return ALLOWED_PUSH_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix));
4559
+ }
4560
+
4327
4561
  function pushSubscriptionId(endpoint) {
4328
4562
  return crypto.createHash("sha1").update(String(endpoint || ""), "utf8").digest("hex").slice(0, 24);
4329
4563
  }
@@ -6846,11 +7080,6 @@ async function buildRolloutFileTimelineEntries({ config, record, fileState, runt
6846
7080
  });
6847
7081
  return [];
6848
7082
  }
6849
- const commandText = extractCommandLineFromFunctionOutput(payload.output ?? "");
6850
- if (!commandText) {
6851
- return [];
6852
- }
6853
- const classified = classifyTimelineCommand(commandText);
6854
7083
  upsertTimelineActivity({
6855
7084
  config,
6856
7085
  runtime,
@@ -6862,19 +7091,7 @@ async function buildRolloutFileTimelineEntries({ config, record, fileState, runt
6862
7091
  source: "codex-tool-output",
6863
7092
  atMs: createdAtMs,
6864
7093
  });
6865
- return [
6866
- buildToolTimelineEntry({
6867
- provider: "codex",
6868
- threadId,
6869
- threadLabel,
6870
- callId,
6871
- createdAtMs,
6872
- fileEventType: classified.fileEventType,
6873
- commandText: classified.commandText || commandText,
6874
- fileRefs: classified.fileRefs,
6875
- cwd: fileState.cwd || "",
6876
- }),
6877
- ].filter(Boolean);
7094
+ return [];
6878
7095
  }
6879
7096
 
6880
7097
  if (payloadType === "custom_tool_call_output") {
@@ -10799,7 +11016,7 @@ function formatCommandApprovalMessage(params, locale = config?.defaultLocale ||
10799
11016
  parts.push(t(locale, "server.message.commandApprovalNeeded"));
10800
11017
  }
10801
11018
  if (command) {
10802
- parts.push(`${t(locale, "server.message.commandLabel")}\n\`\`\`sh\n${command}\n\`\`\``);
11019
+ parts.push(`${t(locale, "server.message.commandLabel")}\n\`\`\`sh\n${escapeMarkdownCodeFenceBody(command)}\n\`\`\``);
10803
11020
  }
10804
11021
  return parts.join("\n\n") || t(locale, "server.message.commandApprovalNeeded");
10805
11022
  }
@@ -12027,6 +12244,10 @@ function normalizePushSubscriptionBody(payload, deviceId) {
12027
12244
  if (!endpoint || !p256dh || !auth || !deviceId) {
12028
12245
  return null;
12029
12246
  }
12247
+ if (!isAllowedPushEndpoint(endpoint)) {
12248
+ console.warn(`[web-push] rejected subscription endpoint host: ${(() => { try { return new URL(endpoint).host; } catch { return "invalid"; } })()}`);
12249
+ return null;
12250
+ }
12030
12251
  return normalizePushSubscriptionRecord({
12031
12252
  id: pushSubscriptionId(endpoint),
12032
12253
  endpoint,
@@ -15551,7 +15772,7 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
15551
15772
  return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
15552
15773
  }
15553
15774
 
15554
- function resolveTimelineEntryImagePath(runtime, state, token, index) {
15775
+ function resolveTimelineEntryImagePath(runtime, state, token, index, config) {
15555
15776
  const normalizedToken = cleanText(token || "");
15556
15777
  const entry = timelineEntryByToken(runtime, normalizedToken)
15557
15778
  || (state?.recentTimelineEntries ?? []).find((candidate) => cleanText(candidate?.token || "") === normalizedToken)
@@ -15561,7 +15782,9 @@ function resolveTimelineEntryImagePath(runtime, state, token, index) {
15561
15782
  }
15562
15783
  const imagePaths = normalizeTimelineImagePaths(entry.imagePaths ?? []);
15563
15784
  const resolvedIndex = Math.max(0, Number(index) || 0);
15564
- return cleanText(imagePaths[resolvedIndex] || "");
15785
+ const candidate = cleanText(imagePaths[resolvedIndex] || "");
15786
+ // Fail closed: only serve images confined to the attachment/upload roots.
15787
+ return resolveConfinedTimelineImagePath(config, candidate);
15565
15788
  }
15566
15789
 
15567
15790
  function resolveWebAsset(urlPath) {
@@ -19275,7 +19498,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
19275
19498
  if (apiTimelineImageMatch && req.method === "GET") {
19276
19499
  const token = decodeURIComponent(apiTimelineImageMatch[1]);
19277
19500
  const index = Number(apiTimelineImageMatch[2]) || 0;
19278
- const filePath = resolveTimelineEntryImagePath(runtime, state, token, index);
19501
+ const filePath = resolveTimelineEntryImagePath(runtime, state, token, index, config);
19279
19502
  if (!filePath) {
19280
19503
  res.statusCode = 404;
19281
19504
  res.end("not-found");
@@ -22332,9 +22555,20 @@ function parseEnvText(text) {
22332
22555
  return output;
22333
22556
  }
22334
22557
 
22335
- function loadEnvFile(filePath) {
22558
+ function loadEnvFile(filePath, { allowKeys = null } = {}) {
22336
22559
  try {
22337
- Object.assign(process.env, parseEnvText(readFileSync(filePath, "utf8")));
22560
+ const parsed = parseEnvText(readFileSync(filePath, "utf8"));
22561
+ if (typeof allowKeys === "function") {
22562
+ // Only copy allow-listed keys into the environment. Used for files derived
22563
+ // from untrusted/remote data (e.g. a2a.env, written from the relay's setup
22564
+ // response) so a tampered file cannot inject keys that override
22565
+ // security-critical config such as AUTH_REQUIRED or SESSION_SECRET.
22566
+ for (const [key, value] of Object.entries(parsed)) {
22567
+ if (allowKeys(key)) process.env[key] = value;
22568
+ }
22569
+ } else {
22570
+ Object.assign(process.env, parsed);
22571
+ }
22338
22572
  } catch {
22339
22573
  // Optional env file.
22340
22574
  }
@@ -22409,8 +22643,12 @@ async function maybeRotateStartupPairingEnv(envFile) {
22409
22643
  }
22410
22644
 
22411
22645
  const nextText = upsertEnvText(currentText, updates);
22412
- await fs.mkdir(path.dirname(envFile), { recursive: true });
22413
- await fs.writeFile(envFile, nextText, "utf8");
22646
+ await fs.mkdir(path.dirname(envFile), { recursive: true, mode: 0o700 });
22647
+ // This env file holds the freshly-rotated PAIRING_TOKEN (trusted control-plane
22648
+ // access) — keep it owner-only. writeFile's mode only applies on create, so
22649
+ // chmod explicitly to also tighten any pre-existing world-readable file.
22650
+ await fs.writeFile(envFile, nextText, { mode: 0o600 });
22651
+ await fs.chmod(envFile, 0o600).catch(() => {});
22414
22652
  }
22415
22653
 
22416
22654
  async function loadState(stateFile) {
@@ -22530,13 +22768,21 @@ async function saveState(stateFile, state) {
22530
22768
  // single newline at the end keeps diffs/hexdumps working on the off chance
22531
22769
  // someone cats the file.
22532
22770
  const output = JSON.stringify(state);
22533
- await fs.mkdir(path.dirname(stateFile), { recursive: true, mode: 0o700 });
22771
+ const stateDir = path.dirname(stateFile);
22772
+ const tmpFile = path.join(stateDir, `.${path.basename(stateFile)}.tmp-${process.pid}-${Date.now()}`);
22773
+ await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
22534
22774
  // state.json holds hazbase wallet tokens, push subscriptions, and device
22535
- // records — keep it owner-only so other local users can't read it. writeFile's
22536
- // mode only applies on create, so chmod explicitly to also tighten any file
22537
- // that was previously written world-readable.
22538
- await fs.writeFile(stateFile, `${output}\n`, { mode: 0o600 });
22539
- await fs.chmod(stateFile, 0o600).catch(() => {});
22775
+ // records — write a private temp file first so ENOSPC never truncates the
22776
+ // last known-good state.json.
22777
+ try {
22778
+ await fs.writeFile(tmpFile, `${output}\n`, { mode: 0o600 });
22779
+ await fs.chmod(tmpFile, 0o600).catch(() => {});
22780
+ await fs.rename(tmpFile, stateFile);
22781
+ await fs.chmod(stateFile, 0o600).catch(() => {});
22782
+ } catch (error) {
22783
+ await fs.unlink(tmpFile).catch(() => {});
22784
+ throw error;
22785
+ }
22540
22786
  }
22541
22787
 
22542
22788
  // ---------------------------------------------------------------------------
@@ -23905,7 +24151,7 @@ async function main() {
23905
24151
  if (now - lastA2aEnvCheckAt > A2A_ENV_CHECK_INTERVAL_MS) {
23906
24152
  lastA2aEnvCheckAt = now;
23907
24153
  try {
23908
- loadEnvFile(path.join(os.homedir(), ".viveworker", "a2a.env"));
24154
+ loadEnvFile(path.join(os.homedir(), ".viveworker", "a2a.env"), { allowKeys: (key) => key.startsWith("A2A_") });
23909
24155
  const newRelayUrl = cleanText(process.env.A2A_RELAY_URL || "");
23910
24156
  const newRelayUserId = cleanText(process.env.A2A_RELAY_USER_ID || "");
23911
24157
  const newRelayRegisterSecret = cleanText(process.env.A2A_RELAY_REGISTER_SECRET || "");