@phren/cli 0.0.20 → 0.0.22

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/mcp/dist/hooks.js CHANGED
@@ -63,8 +63,13 @@ function resolveCliEntryScript() {
63
63
  function phrenPackageSpec() {
64
64
  return PACKAGE_SPEC;
65
65
  }
66
+ /** Shell-escape a value by wrapping in single quotes with proper escaping of embedded single quotes. */
67
+ export function shellEscape(s) {
68
+ return "'" + s.replace(/'/g, "'\\''") + "'";
69
+ }
70
+ /** @deprecated Use shellEscape instead */
66
71
  function shellSingleQuote(value) {
67
- return `'${value.replace(/'/g, `'\"'\"'`)}'`;
72
+ return shellEscape(value);
68
73
  }
69
74
  function buildPackageLifecycleCommands() {
70
75
  const packageSpec = phrenPackageSpec();
@@ -280,7 +285,7 @@ export function validateCustomHookCommand(command) {
280
285
  if (trimmed.length > 1000)
281
286
  return "Command too long (max 1000 characters).";
282
287
  if (/[`$(){}&|;<>\n\r#]/.test(trimmed)) {
283
- return "Command contains disallowed shell characters: ` $ ( ) { } & | ; < >";
288
+ return "Command contains disallowed shell characters: ` $ ( ) { } & | ; < > # \\n \\r";
284
289
  }
285
290
  if (/\b(eval|source)\b/.test(trimmed))
286
291
  return "eval and source are not permitted in hook commands.";
@@ -352,27 +357,52 @@ export function validateCustomWebhookUrl(webhook) {
352
357
  }
353
358
  return blockedWebhookHostnameReason(parsed.hostname);
354
359
  }
355
- async function validateWebhookAtExecution(webhook) {
360
+ /**
361
+ * Validate a webhook URL at execution time and return the resolved IP address
362
+ * to use for the fetch. Resolving once and re-using the IP prevents DNS
363
+ * rebinding attacks where the hostname resolves to a safe IP during validation
364
+ * but a private/loopback IP when fetch performs its own lookup.
365
+ *
366
+ * Returns { error } if blocked, or { resolvedUrl, host } if safe.
367
+ */
368
+ async function validateAndResolveWebhook(webhook) {
356
369
  let parsed;
357
370
  try {
358
371
  parsed = new URL(webhook);
359
372
  }
360
373
  catch {
361
- return "webhook is not a valid URL.";
374
+ return { error: "webhook is not a valid URL." };
362
375
  }
363
376
  const literalBlock = blockedWebhookHostnameReason(parsed.hostname);
364
377
  if (literalBlock)
365
- return literalBlock;
378
+ return { error: literalBlock };
379
+ // If the hostname is already a literal IP, validate it directly
380
+ if (isIP(parsed.hostname)) {
381
+ if (isPrivateOrLoopbackAddress(parsed.hostname)) {
382
+ return { error: `webhook hostname "${parsed.hostname}" is a private or loopback address.` };
383
+ }
384
+ return { resolvedUrl: webhook, host: parsed.hostname };
385
+ }
366
386
  try {
367
387
  const records = await lookup(parsed.hostname, { all: true, verbatim: true });
388
+ if (records.length === 0) {
389
+ return { error: `webhook hostname "${parsed.hostname}" did not resolve to any address.` };
390
+ }
368
391
  if (records.some((record) => isPrivateOrLoopbackAddress(record.address))) {
369
- return `webhook hostname "${parsed.hostname}" resolved to a private or loopback address.`;
392
+ return { error: `webhook hostname "${parsed.hostname}" resolved to a private or loopback address.` };
370
393
  }
394
+ // Use the first resolved IP to build a pinned URL, preventing DNS rebinding
395
+ const resolvedIp = records[0].address;
396
+ const pinnedUrl = new URL(webhook);
397
+ pinnedUrl.hostname = records[0].family === 6 ? `[${resolvedIp}]` : resolvedIp;
398
+ return { resolvedUrl: pinnedUrl.href, host: parsed.host };
371
399
  }
372
400
  catch (err) {
373
- debugLog(`validateWebhookAtExecution lookup failed for ${parsed.hostname}: ${errorMessage(err)}`);
401
+ debugLog(`validateAndResolveWebhook lookup failed for ${parsed.hostname}: ${errorMessage(err)}`);
402
+ // DNS resolution failed; allow the fetch to proceed with the original URL
403
+ // (fetch will do its own resolution and may fail with a network error)
404
+ return { resolvedUrl: webhook, host: parsed.host };
374
405
  }
375
- return null;
376
406
  }
377
407
  const DEFAULT_CUSTOM_HOOK_TIMEOUT = 5000;
378
408
  const HOOK_TIMEOUT_MS = parseInt(process.env.PHREN_HOOK_TIMEOUT_MS || '14000', 10);
@@ -428,17 +458,24 @@ export function runCustomHooks(phrenPath, event, env = {}) {
428
458
  if (hook.secret) {
429
459
  headers["X-Phren-Signature"] = `sha256=${createHmac("sha256", hook.secret).update(payload).digest("hex")}`;
430
460
  }
431
- void validateWebhookAtExecution(hook.webhook)
432
- .then((blockReason) => {
433
- if (blockReason) {
434
- const message = `${event}: skipped webhook ${hook.webhook}: ${blockReason}`;
461
+ void validateAndResolveWebhook(hook.webhook)
462
+ .then((result) => {
463
+ if ("error" in result && result.error) {
464
+ const message = `${event}: skipped webhook ${hook.webhook}: ${result.error}`;
435
465
  debugLog(`runCustomHooks webhook: ${message}`);
436
466
  appendHookErrorLog(phrenPath, event, message);
437
467
  return;
438
468
  }
439
- return fetch(hook.webhook, {
469
+ // Use the pinned resolved URL to prevent DNS rebinding;
470
+ // set Host header to the original hostname for correct routing.
471
+ const { resolvedUrl, host } = result;
472
+ const fetchHeaders = { ...headers };
473
+ if (host) {
474
+ fetchHeaders["Host"] = host;
475
+ }
476
+ return fetch(resolvedUrl, {
440
477
  method: "POST",
441
- headers,
478
+ headers: fetchHeaders,
442
479
  body: payload,
443
480
  redirect: "manual",
444
481
  signal: AbortSignal.timeout(hook.timeout ?? DEFAULT_CUSTOM_HOOK_TIMEOUT),
@@ -11,6 +11,7 @@ const importPayloadSchema = z.object({
11
11
  summary: z.string().optional(),
12
12
  claudeMd: z.string().optional(),
13
13
  taskRaw: z.string().optional(),
14
+ findingsRaw: z.string().optional(),
14
15
  learnings: z
15
16
  .array(z.object({
16
17
  text: z.string(),
@@ -134,9 +135,8 @@ export function register(server, ctx) {
134
135
  };
135
136
  const buildTaskContent = () => {
136
137
  // Prefer the raw task string (lossless: preserves priority/pinned/stable IDs)
137
- const taskRaw = parsed.taskRaw;
138
- if (typeof taskRaw === "string")
139
- return taskRaw;
138
+ if (typeof parsed.taskRaw === "string")
139
+ return parsed.taskRaw;
140
140
  if (!parsed.task)
141
141
  return null;
142
142
  const sections = ["Active", "Queue", "Done"];
@@ -175,8 +175,7 @@ export function register(server, ctx) {
175
175
  fs.writeFileSync(path.join(stagedProjectDir, "CLAUDE.md"), parsed.claudeMd);
176
176
  imported.push("CLAUDE.md");
177
177
  }
178
- const findingsRaw = parsed.findingsRaw;
179
- const findingsContent = typeof findingsRaw === "string" ? findingsRaw : buildFindingsContent();
178
+ const findingsContent = typeof parsed.findingsRaw === "string" ? parsed.findingsRaw : buildFindingsContent();
180
179
  if (findingsContent) {
181
180
  fs.writeFileSync(path.join(stagedProjectDir, "FINDINGS.md"), findingsContent);
182
181
  imported.push("FINDINGS.md");