@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/generated/memory-ui-graph.browser.js +22 -22
- package/mcp/dist/hooks.js +51 -14
- package/mcp/dist/mcp-data.js +4 -5
- package/mcp/dist/memory-ui-assets.js +2 -2
- package/mcp/dist/memory-ui-data.js +1 -1
- package/mcp/dist/memory-ui-graph.runtime.js +22 -22
- package/mcp/dist/memory-ui-page.js +71 -44
- package/mcp/dist/memory-ui-scripts.js +37 -555
- package/mcp/dist/memory-ui-server.js +0 -34
- package/mcp/dist/memory-ui-styles.js +137 -136
- package/mcp/dist/profile-store.js +1 -13
- package/mcp/dist/shared-ollama.js +1 -12
- package/mcp/dist/shell-state-store.js +2 -14
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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(`
|
|
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
|
|
432
|
-
.then((
|
|
433
|
-
if (
|
|
434
|
-
const message = `${event}: skipped webhook ${hook.webhook}: ${
|
|
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
|
-
|
|
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),
|
package/mcp/dist/mcp-data.js
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
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
|
|
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");
|