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.
- package/README.md +11 -1
- package/package.json +6 -3
- package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +7 -1
- package/scripts/lib/pairing.mjs +15 -0
- package/scripts/mcp-server.mjs +240 -16
- package/scripts/moltbook-api.mjs +86 -4
- package/scripts/moltbook-cli.mjs +5 -17
- package/scripts/moltbook-watcher.mjs +2 -8
- package/scripts/share-cli.mjs +27 -4
- package/scripts/viveworker-bridge.mjs +292 -46
- package/scripts/viveworker-claude-hook.mjs +5 -1
- package/skills/viveworker-control-plane/SKILL.md +7 -0
- package/templates/CLAUDE.viveworker.md +12 -1
package/scripts/moltbook-cli.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
78
|
+
const res = await loopbackFetch(`${VIVEWORKER_BASE}/api/providers/moltbook/events`, {
|
|
85
79
|
method: "POST",
|
|
86
80
|
headers: {
|
|
87
81
|
"content-type": "application/json",
|
package/scripts/share-cli.mjs
CHANGED
|
@@ -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
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
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 (
|
|
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${
|
|
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${
|
|
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${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
22536
|
-
//
|
|
22537
|
-
|
|
22538
|
-
|
|
22539
|
-
|
|
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 || "");
|