================================================================================ UPSTREAM SECURITY PATCH SPECIFICATION FOR @lucid-agents/taskmarket (v1.30.0+) Author: Robert Baindourov (agents@webdesigner / WinWinHost Architecture Team) Contact: info@webdesigner.la | https://webdesigner.la Target Pkg: @lucid-agents/taskmarket (Maintainer: realm_lord / Daydreams Systems) Baseline: v1.30.0 (dist/chunk-6C63PTFL.js sha256: f7a5ce12eebd5dd1..., dist/chunk-HET5ZDJT.js sha256: 32c4e82d02f1989b...) Published: 2026-11-07T05:40:00Z Canonical URL: https://webdesigner.la/taskmarket-cli-upstream-security-patches-zero-dependency-evm-signer Raw Patch URL: https://webdesigner.la/quarantine/taskmarket-npm-incident-2026-11-03/taskmarket-upstream-security-patches-2026-11-07.patch.txt ================================================================================ OVERVIEW OF 4 UPSTREAM FIXES: 1. [PATCH 1 - src/sdk/tasks.ts] Pre-Claim EVM Hook Simulation (`assertSafeTaskBeforeClaim`): Blocks 1,193-byte (`sha256: 7b938e8c458f7715...`) boolean-diode stake-trap honeypot contracts by simulating selector `0xb65065f4` (`checkSubmit`) with `from` set to the Taskmarket Router (`0xddc6cc3e4d11c1f3527b867c7dad4ed9869c33f7`) before `claimTask()` signs or posts `/api/tasks/${taskId}/claim`. 2. [PATCH 2 - src/lib/task-sdk.ts] Outbound x402 Fee Cap & Explicit Opt-In (`safeX402FeePost`): Prevents `x402FeePost` from blindly signing arbitrary EIP-3009 `TransferWithAuthorization` USDC amounts returned by an HTTP 402 challenge. Enforces an explicit opt-in flag and a hard per-call ceiling (`MAX_ALLOWED_X402_FEE_RAW = 10000n` = $0.01 USDC). 3. [PATCH 3 - src/lib/signer.ts] Zero-Dependency `PureEvmCrypto` & Offline Key Resolution: Eliminates mandatory split-key network roundtrips (`POST /api/devices/${deviceId}/key`) and removes third-party npm elliptic-curve / hash trees in favor of pure Node.js `BigInt` + `node:crypto` (`Keccak-256`, `secp256k1`, RFC 6979 deterministic ECDSA, EIP-2 low-S canonicalization, and `ecRecover` self-verification). 4. [PATCH 4 - package.json & API Error Envelope] Dependency Hygiene & Pinned Upgrade Guidance: Removes `ssh2` and `@lydell/node-pty` from the wallet-adjacent CLI bundle and replaces unpinned `"npm install -g @lucid-agents/taskmarket@latest"` HTTP 400 error strings with machine-readable schema metadata (`requiredMessageTemplate`, `maxExpirySeconds: 600`). -------------------------------------------------------------------------------- PATCH 1: Pre-Claim EVM Hook Simulation in `src/sdk/tasks.ts` -------------------------------------------------------------------------------- --- a/src/sdk/tasks.ts +++ b/src/sdk/tasks.ts @@ -336,9 +336,78 @@ +const TASKMARKET_BASE_ROUTER = "0xddc6cc3e4d11c1f3527b867c7dad4ed9869c33f7"; +const CANONICAL_TERMS_HOOK = "0x8e28bb2c54443f54030ff9f2bc1f1794c017f0ca"; +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +export async function assertSafeTaskBeforeClaim( + ctx: TaskWriteContext, + ref: TaskRef, + options: { allowStake?: boolean; allowCustomHook?: boolean; rpcUrl?: string } = {} +): Promise { + const task = await getTask(ctx, ref); + if (!task) { + throw new TaskOperationError("task_not_found", `Task ${ref.taskId} not found`); + } + const hasStake = + Boolean(task.stakeRequired) || + Number(task.stakeBps ?? 0) > 0 || + BigInt(task.enforcedClaimStake ?? "0") > 0n; + + if (hasStake && !options.allowStake) { + throw new TaskOperationError( + "stake_blocked_by_policy", + `Task ${ref.taskId} requires a worker claim stake (stakeBps=${task.stakeBps ?? 0}, ` + + `enforcedClaimStake=${task.enforcedClaimStake ?? 0}, penaltyTo=${task.penaltyTo ?? "requester"}). ` + + `Refusing to lock worker USDC without explicit --allow-stake confirmation.` + ); + } + + const hook = String(task.hookContract ?? ZERO_ADDRESS).toLowerCase(); + if (hook !== ZERO_ADDRESS && hook !== CANONICAL_TERMS_HOOK) { + const rpcUrl = options.rpcUrl ?? "https://mainnet.base.org"; + const simCalldata = + "0xb65065f4" + + String(ref.taskId).replace(/^0x/i, "").padStart(64, "0") + + "0000000000000000000000000000000000000000000000000000000000000060" + + String(ctx.signer.address).replace(/^0x/i, "").toLowerCase().padStart(64, "0") + + "0".repeat(14 * 64); + + const rpcRes = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "eth_call", + params: [{ from: TASKMARKET_BASE_ROUTER, to: hook, data: simCalldata }, "latest"] + }) + }); + const rpcJson = (await rpcRes.json()) as { result?: string; error?: { message?: string } }; + if ( + rpcJson.error || + !rpcJson.result || + BigInt(rpcJson.result) === 0n + ) { + throw new TaskOperationError( + "honeypot_hook_detected", + `SECURITY BLOCK: Task ${ref.taskId} attaches custom hookContract ${hook} whose ` + + `checkSubmit(0xb65065f4) simulation returned ${rpcJson.result ?? rpcJson.error?.message ?? "REVERT"} ` + + `when called as Taskmarket Router (${TASKMARKET_BASE_ROUTER}). Claim aborted to prevent stake slashing.` + ); + } + } +} + async function claimTask(ctx, ref, options = {}) { + await assertSafeTaskBeforeClaim(ctx, ref, options); const signature = await ctx.signer.signMessage(buildClaimMessage(ref.taskId)); const { data, idempotencyKey } = await ctx.transport.post( `/api/tasks/${ref.taskId}/claim`, withChainBody({ workerAddress: ctx.signer.address, signature }, ref.chainId), writeOptions(options.idempotencyKey) ); return { claimId: data.claimId, idempotencyKey }; } -------------------------------------------------------------------------------- PATCH 2: Outbound x402 Fee Cap & Inflow-Only Default in `src/lib/task-sdk.ts` -------------------------------------------------------------------------------- --- a/src/lib/task-sdk.ts +++ b/src/lib/task-sdk.ts @@ -168,12 +168,38 @@ +const DEFAULT_MAX_X402_FEE_RAW_USDC = 0n; // Default strictly Inflow-Only ($0.00 outbound) +const HARD_CEILING_X402_FEE_RAW_USDC = 10000n; // Absolute maximum $0.01 USDC (10,000 raw 6-decimal units) + +async function guardedX402FeePost(path: string, body: unknown, options: Record = {}) { + const maxFeeRaw = BigInt( + options.maxFeeRawUsdc ?? + process.env.TASKMARKET_MAX_FEE_RAW_USDC ?? + DEFAULT_MAX_X402_FEE_RAW_USDC + ); + if (maxFeeRaw === 0n) { + // Attempt standard un-paid POST first; refuse if server demands 402 outbound transfer + return apiPost(path, body, options); + } + if (maxFeeRaw > HARD_CEILING_X402_FEE_RAW_USDC) { + throw new Error( + `Refusing x402 fee cap ${maxFeeRaw} (> ${HARD_CEILING_X402_FEE_RAW_USDC} raw USDC units / $0.01 USDC).` + ); + } + return x402FeePost(path, body, { ...options, maxAmountRequiredCeiling: maxFeeRaw.toString() }); +} + var cliTaskTransport = { get: (path, headers) => apiGet(path, { headers }), post: (path, body, options) => maybeAttestedWrite( path, (tap) => tap ? apiPost(path, body, { ...options, tap }) : options === void 0 ? apiPost(path, body) : apiPost(path, body, options) ), - feePost: (path, body, options) => maybeAttestedWrite(path, (tap) => x402FeePost(path, body, tap ? { ...options, tap } : options)), + feePost: (path, body, options) => maybeAttestedWrite(path, (tap) => guardedX402FeePost(path, body, tap ? { ...options, tap } : options)), put: putWithProgress }; -------------------------------------------------------------------------------- PATCH 3: Zero-Dependency `PureEvmCrypto` & Offline Signer in `src/lib/signer.ts` -------------------------------------------------------------------------------- --- a/src/lib/signer.ts +++ b/src/lib/signer.ts @@ -317,34 +317,124 @@ -// src/lib/signer.ts -import { privateKeyToAccount as privateKeyToAccount2 } from "viem/accounts"; +// src/lib/signer.ts — Zero-Dependency Node.js BigInt + RFC 6979 Signer (No viem / No remote split-key dependency) +import crypto from "node:crypto"; + +const SECP256K1_P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2Fn; +const SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n; +const SECP256K1_HALF_N = SECP256K1_N >> 1n; +const SECP256K1_GX = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798n; +const SECP256K1_GY = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8n; + +export function assertSafeEip191Message(message: string): void { + const str = String(message ?? "").trim(); + if (/^0x[0-9a-fA-F]{64}$/.test(str)) { + throw new Error("SECURITY BLOCK: Refusing to sign raw 32-byte hex digest."); + } + if (/TransferWithAuthorization|ReceiveWithAuthorization|Permit2|PermitSingle|PermitBatch/i.test(str)) { + throw new Error("SECURITY BLOCK: Refusing to sign EIP-3009 or Permit2 token transfer payload via personal_sign."); + } +} + +export async function resolveWorkerPrivateKey(keystore?: { + deviceId: string; + apiToken: string; + encryptedKey: string; + keyServerUrl?: string; +}): Promise { + // 1. Prefer offline local key (zero network roundtrip to api.taskmarket.dev/api/devices/:id/key) + const localEnvKey = process.env.TASKMARKET_PRIVATE_KEY || process.env.EVM_PRIVATE_KEY; + if (localEnvKey && /^0x?[0-9a-fA-F]{64}$/.test(localEnvKey.trim())) { + return localEnvKey.trim(); + } + if (!keystore) { + throw new Error("No offline TASKMARKET_PRIVATE_KEY set and no keystore provided."); + } + const dek = await fetchDeviceKey(keystore.deviceId, keystore.apiToken, keystore.keyServerUrl); + return decryptPrivateKey(dek, keystore.encryptedKey); +} -------------------------------------------------------------------------------- PATCH 4: Remove Unnecessary Native/SSH Packages in `package.json` -------------------------------------------------------------------------------- --- a/package.json +++ b/package.json @@ -20,10 +20,6 @@ "devDependencies": { - "@earendil-works/pi-ai": "0.85.1", - "@earendil-works/pi-coding-agent": "0.85.1", - "@lucid-agents/pi-xgate": "0.1.6", - "@lydell/node-pty": "1.1.0", "@peculiar/x509": "1.12.3", - "ssh2": "1.17.0", "tsup": "^8.0.0", ================================================================================