@phren/cli 0.0.15 → 0.0.16
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/capabilities/cli.js +1 -1
- package/mcp/dist/capabilities/mcp.js +1 -1
- package/mcp/dist/capabilities/vscode.js +1 -1
- package/mcp/dist/capabilities/web-ui.js +1 -1
- package/mcp/dist/cli-graph.js +4 -1
- package/mcp/dist/cli-hooks-context.js +1 -1
- package/mcp/dist/cli-namespaces.js +17 -7
- package/mcp/dist/hooks.js +126 -26
- package/mcp/dist/mcp-data.js +38 -39
- package/mcp/dist/mcp-graph.js +2 -2
- package/mcp/dist/mcp-hooks.js +5 -64
- package/mcp/dist/mcp-skills.js +13 -5
- package/mcp/dist/memory-ui-assets.js +2 -1
- package/mcp/dist/memory-ui-graph.js +26 -12
- package/mcp/dist/memory-ui-page.js +2 -2
- package/mcp/dist/memory-ui-scripts.js +34 -18
- package/mcp/dist/memory-ui-server.js +12 -5
- package/mcp/dist/phren-paths.js +17 -0
- package/mcp/dist/shared.js +1 -1
- package/mcp/dist/skill-registry.js +25 -2
- package/package.json +1 -1
package/mcp/dist/cli-graph.js
CHANGED
|
@@ -146,7 +146,10 @@ export async function handleGraphLink(args) {
|
|
|
146
146
|
try {
|
|
147
147
|
existing = JSON.parse(fs.readFileSync(manualLinksPath, "utf8"));
|
|
148
148
|
}
|
|
149
|
-
catch {
|
|
149
|
+
catch (err) {
|
|
150
|
+
process.stderr.write(`[phren] link_fragment: manual-links.json is malformed — aborting to avoid data loss: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
150
153
|
}
|
|
151
154
|
const newEntry = { entity: normalizedFragment, entityType: "fragment", sourceDoc, relType: "mentions" };
|
|
152
155
|
const alreadyStored = existing.some((e) => e.entity === newEntry.entity && e.entityType === newEntry.entityType && e.sourceDoc === newEntry.sourceDoc && e.relType === newEntry.relType);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// cli-hooks-context.ts — HookContext: everything a hook handler needs as plain data + helpers.
|
|
2
2
|
// Centralizes the "resolve state and check guards" pattern so hook handlers don't
|
|
3
3
|
// reach into governance, init, project-config, hooks, init-setup, etc. directly.
|
|
4
|
-
import {
|
|
4
|
+
import { getPhrenPath, readRootManifest, appendAuditLog, } from "./shared.js";
|
|
5
5
|
import { updateRuntimeHealth, } from "./shared-governance.js";
|
|
6
6
|
import { detectProject } from "./shared-index.js";
|
|
7
7
|
import { getHooksEnabledPreference } from "./init.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { execFileSync } from "child_process";
|
|
4
|
-
import { expandHomePath, findProjectNameCaseInsensitive, getPhrenPath, getProjectDirs, homePath, hookConfigPath, normalizeProjectNameForCreate, readRootManifest, } from "./shared.js";
|
|
4
|
+
import { expandHomePath, findArchivedProjectNameCaseInsensitive, findProjectNameCaseInsensitive, getPhrenPath, getProjectDirs, homePath, hookConfigPath, normalizeProjectNameForCreate, readRootManifest, } from "./shared.js";
|
|
5
5
|
import { isValidProjectName, errorMessage } from "./utils.js";
|
|
6
6
|
import { readInstallPreferences, writeInstallPreferences } from "./init-preferences.js";
|
|
7
7
|
import { buildSkillManifest, findLocalSkill, findSkill, getAllSkills } from "./skill-registry.js";
|
|
@@ -13,7 +13,7 @@ import { buildTaskIssueBody, createGithubIssueForTask, parseGithubIssueUrl, reso
|
|
|
13
13
|
import { PROJECT_HOOK_EVENTS, PROJECT_OWNERSHIP_MODES, isProjectHookEnabled, parseProjectOwnershipMode, readProjectConfig, writeProjectConfig, writeProjectHookConfig, } from "./project-config.js";
|
|
14
14
|
import { addFinding, removeFinding } from "./core-finding.js";
|
|
15
15
|
import { supersedeFinding, retractFinding, resolveFindingContradiction } from "./finding-lifecycle.js";
|
|
16
|
-
import { readCustomHooks, getHookTarget, HOOK_EVENT_VALUES } from "./hooks.js";
|
|
16
|
+
import { readCustomHooks, getHookTarget, HOOK_EVENT_VALUES, validateCustomHookCommand } from "./hooks.js";
|
|
17
17
|
import { runtimeFile } from "./shared.js";
|
|
18
18
|
const HOOK_TOOLS = ["claude", "copilot", "cursor", "codex"];
|
|
19
19
|
function printSkillsUsage() {
|
|
@@ -334,6 +334,11 @@ export function handleHooksNamespace(args) {
|
|
|
334
334
|
console.error(`Invalid event "${event}". Valid events: ${HOOK_EVENT_VALUES.join(", ")}`);
|
|
335
335
|
process.exit(1);
|
|
336
336
|
}
|
|
337
|
+
const commandErr = validateCustomHookCommand(command);
|
|
338
|
+
if (commandErr) {
|
|
339
|
+
console.error(commandErr);
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
337
342
|
const phrenPath = getPhrenPath();
|
|
338
343
|
const prefs = readInstallPreferences(phrenPath);
|
|
339
344
|
const existing = Array.isArray(prefs.customHooks) ? prefs.customHooks : [];
|
|
@@ -790,9 +795,10 @@ export async function handleProjectsNamespace(args, profile) {
|
|
|
790
795
|
process.exit(1);
|
|
791
796
|
}
|
|
792
797
|
const phrenPath = getPhrenPath();
|
|
793
|
-
const projectDir = path.join(phrenPath, name);
|
|
794
|
-
const archiveDir = path.join(phrenPath, `${name}.archived`);
|
|
795
798
|
if (subcommand === "archive") {
|
|
799
|
+
const activeProject = findProjectNameCaseInsensitive(phrenPath, name);
|
|
800
|
+
const projectDir = activeProject ? path.join(phrenPath, activeProject) : path.join(phrenPath, name);
|
|
801
|
+
const archiveDir = path.join(phrenPath, `${activeProject ?? name}.archived`);
|
|
796
802
|
if (!fs.existsSync(projectDir)) {
|
|
797
803
|
console.error(`Project "${name}" not found.`);
|
|
798
804
|
process.exit(1);
|
|
@@ -812,10 +818,14 @@ export async function handleProjectsNamespace(args, profile) {
|
|
|
812
818
|
}
|
|
813
819
|
}
|
|
814
820
|
else {
|
|
815
|
-
|
|
816
|
-
|
|
821
|
+
const activeProject = findProjectNameCaseInsensitive(phrenPath, name);
|
|
822
|
+
if (activeProject) {
|
|
823
|
+
console.error(`Project "${activeProject}" already exists as an active project.`);
|
|
817
824
|
process.exit(1);
|
|
818
825
|
}
|
|
826
|
+
const archivedProject = findArchivedProjectNameCaseInsensitive(phrenPath, name);
|
|
827
|
+
const projectDir = path.join(phrenPath, archivedProject ?? name);
|
|
828
|
+
const archiveDir = path.join(phrenPath, `${archivedProject ?? name}.archived`);
|
|
819
829
|
if (!fs.existsSync(archiveDir)) {
|
|
820
830
|
const available = fs.readdirSync(phrenPath)
|
|
821
831
|
.filter((e) => e.endsWith(".archived"))
|
|
@@ -830,7 +840,7 @@ export async function handleProjectsNamespace(args, profile) {
|
|
|
830
840
|
}
|
|
831
841
|
try {
|
|
832
842
|
fs.renameSync(archiveDir, projectDir);
|
|
833
|
-
console.log(`Unarchived project "${name}". It is now active again.`);
|
|
843
|
+
console.log(`Unarchived project "${archivedProject ?? name}". It is now active again.`);
|
|
834
844
|
console.log("Note: the search index will be updated on next search.");
|
|
835
845
|
}
|
|
836
846
|
catch (err) {
|
package/mcp/dist/hooks.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { createHmac } from "crypto";
|
|
4
|
+
import { lookup } from "dns/promises";
|
|
4
5
|
import { execFileSync } from "child_process";
|
|
6
|
+
import { isIP } from "net";
|
|
5
7
|
import { fileURLToPath } from "url";
|
|
6
8
|
import { EXEC_TIMEOUT_QUICK_MS, PhrenError, debugLog, runtimeFile, homePath, installPreferencesFile, atomicWriteText } from "./shared.js";
|
|
7
9
|
import { errorMessage } from "./utils.js";
|
|
@@ -61,6 +63,9 @@ function resolveCliEntryScript() {
|
|
|
61
63
|
function phrenPackageSpec() {
|
|
62
64
|
return PACKAGE_SPEC;
|
|
63
65
|
}
|
|
66
|
+
function shellSingleQuote(value) {
|
|
67
|
+
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
|
68
|
+
}
|
|
64
69
|
function buildPackageLifecycleCommands() {
|
|
65
70
|
const packageSpec = phrenPackageSpec();
|
|
66
71
|
return {
|
|
@@ -74,8 +79,10 @@ export function buildLifecycleCommands(phrenPath) {
|
|
|
74
79
|
const entry = resolveCliEntryScript();
|
|
75
80
|
const isWindows = process.platform === "win32";
|
|
76
81
|
const escapedPhren = phrenPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
82
|
+
const quotedPhren = shellSingleQuote(phrenPath);
|
|
77
83
|
if (entry) {
|
|
78
84
|
const escapedEntry = entry.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
85
|
+
const quotedEntry = shellSingleQuote(entry);
|
|
79
86
|
if (isWindows) {
|
|
80
87
|
return {
|
|
81
88
|
sessionStart: `set "PHREN_PATH=${escapedPhren}" && node "${escapedEntry}" hook-session-start`,
|
|
@@ -85,10 +92,10 @@ export function buildLifecycleCommands(phrenPath) {
|
|
|
85
92
|
};
|
|
86
93
|
}
|
|
87
94
|
return {
|
|
88
|
-
sessionStart: `PHREN_PATH
|
|
89
|
-
userPromptSubmit: `PHREN_PATH
|
|
90
|
-
stop: `PHREN_PATH
|
|
91
|
-
hookTool: `PHREN_PATH
|
|
95
|
+
sessionStart: `PHREN_PATH=${quotedPhren} node ${quotedEntry} hook-session-start`,
|
|
96
|
+
userPromptSubmit: `PHREN_PATH=${quotedPhren} node ${quotedEntry} hook-prompt`,
|
|
97
|
+
stop: `PHREN_PATH=${quotedPhren} node ${quotedEntry} hook-stop`,
|
|
98
|
+
hookTool: `PHREN_PATH=${quotedPhren} node ${quotedEntry} hook-tool`,
|
|
92
99
|
};
|
|
93
100
|
}
|
|
94
101
|
const packageSpec = phrenPackageSpec();
|
|
@@ -101,10 +108,10 @@ export function buildLifecycleCommands(phrenPath) {
|
|
|
101
108
|
};
|
|
102
109
|
}
|
|
103
110
|
return {
|
|
104
|
-
sessionStart: `PHREN_PATH
|
|
105
|
-
userPromptSubmit: `PHREN_PATH
|
|
106
|
-
stop: `PHREN_PATH
|
|
107
|
-
hookTool: `PHREN_PATH
|
|
111
|
+
sessionStart: `PHREN_PATH=${quotedPhren} npx -y ${packageSpec} hook-session-start`,
|
|
112
|
+
userPromptSubmit: `PHREN_PATH=${quotedPhren} npx -y ${packageSpec} hook-prompt`,
|
|
113
|
+
stop: `PHREN_PATH=${quotedPhren} npx -y ${packageSpec} hook-stop`,
|
|
114
|
+
hookTool: `PHREN_PATH=${quotedPhren} npx -y ${packageSpec} hook-tool`,
|
|
108
115
|
};
|
|
109
116
|
}
|
|
110
117
|
export function buildSharedLifecycleCommands() {
|
|
@@ -114,7 +121,7 @@ function withHookToolEnv(command, tool) {
|
|
|
114
121
|
if (process.platform === "win32") {
|
|
115
122
|
return `set "PHREN_HOOK_TOOL=${tool}" && ${command}`;
|
|
116
123
|
}
|
|
117
|
-
return `PHREN_HOOK_TOOL
|
|
124
|
+
return `PHREN_HOOK_TOOL=${shellSingleQuote(tool)} ${command}`;
|
|
118
125
|
}
|
|
119
126
|
function withHookToolLifecycleCommands(lifecycle, tool) {
|
|
120
127
|
return {
|
|
@@ -131,9 +138,6 @@ function installSessionWrapper(tool, phrenPath) {
|
|
|
131
138
|
const entry = resolveCliEntryScript();
|
|
132
139
|
const localBinDir = homePath(".local", "bin");
|
|
133
140
|
const wrapperPath = path.join(localBinDir, tool);
|
|
134
|
-
const escapedBinary = realBinary.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
135
|
-
const escapedPhren = phrenPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
136
|
-
const escapedEntry = entry ? entry.replace(/\\/g, "\\\\").replace(/"/g, '\\"') : "";
|
|
137
141
|
const packageSpec = phrenPackageSpec();
|
|
138
142
|
const sessionStartCmd = entry
|
|
139
143
|
? `env PHREN_PATH="$PHREN_PATH" node "$ENTRY_SCRIPT" hook-session-start`
|
|
@@ -144,9 +148,10 @@ function installSessionWrapper(tool, phrenPath) {
|
|
|
144
148
|
const content = `#!/bin/sh
|
|
145
149
|
set -u
|
|
146
150
|
|
|
147
|
-
REAL_BIN
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
REAL_BIN=${shellSingleQuote(realBinary)}
|
|
152
|
+
DEFAULT_PHREN_PATH=${shellSingleQuote(phrenPath)}
|
|
153
|
+
PHREN_PATH="\${PHREN_PATH:-$DEFAULT_PHREN_PATH}"
|
|
154
|
+
ENTRY_SCRIPT=${shellSingleQuote(entry || "")}
|
|
150
155
|
export PHREN_HOOK_TOOL="${tool}"
|
|
151
156
|
|
|
152
157
|
if [ ! -x "$REAL_BIN" ]; then
|
|
@@ -268,21 +273,107 @@ const VALID_HOOK_EVENTS = new Set(HOOK_EVENT_VALUES);
|
|
|
268
273
|
export function getHookTarget(h) {
|
|
269
274
|
return "webhook" in h ? h.webhook : h.command;
|
|
270
275
|
}
|
|
271
|
-
|
|
272
|
-
function validateCommandAtExecution(command) {
|
|
276
|
+
export function validateCustomHookCommand(command) {
|
|
273
277
|
const trimmed = command.trim();
|
|
274
278
|
if (!trimmed)
|
|
275
279
|
return "Command cannot be empty.";
|
|
276
280
|
if (trimmed.length > 1000)
|
|
277
281
|
return "Command too long (max 1000 characters).";
|
|
278
|
-
if (/[`$(){}
|
|
279
|
-
return "Command contains disallowed shell characters
|
|
282
|
+
if (/[`$(){}&|;<>\n\r#]/.test(trimmed)) {
|
|
283
|
+
return "Command contains disallowed shell characters: ` $ ( ) { } & | ; < >";
|
|
284
|
+
}
|
|
280
285
|
if (/\b(eval|source)\b/.test(trimmed))
|
|
281
286
|
return "eval and source are not permitted in hook commands.";
|
|
282
287
|
if (!/^[\w./~"'"]/.test(trimmed))
|
|
283
288
|
return "Command must begin with an executable name or path.";
|
|
284
289
|
return null;
|
|
285
290
|
}
|
|
291
|
+
function normalizeWebhookHostname(hostname) {
|
|
292
|
+
return hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
293
|
+
}
|
|
294
|
+
function isPrivateOrLoopbackIpv4(address) {
|
|
295
|
+
const octets = address.split(".").map((part) => Number.parseInt(part, 10));
|
|
296
|
+
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
|
|
297
|
+
return false;
|
|
298
|
+
if (octets[0] === 0 || octets[0] === 10 || octets[0] === 127)
|
|
299
|
+
return true;
|
|
300
|
+
if (octets[0] === 169 && octets[1] === 254)
|
|
301
|
+
return true;
|
|
302
|
+
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)
|
|
303
|
+
return true;
|
|
304
|
+
if (octets[0] === 192 && octets[1] === 168)
|
|
305
|
+
return true;
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
function isPrivateOrLoopbackAddress(address) {
|
|
309
|
+
const normalized = address.toLowerCase();
|
|
310
|
+
const ipVersion = isIP(normalized);
|
|
311
|
+
if (ipVersion === 4)
|
|
312
|
+
return isPrivateOrLoopbackIpv4(normalized);
|
|
313
|
+
if (ipVersion !== 6)
|
|
314
|
+
return false;
|
|
315
|
+
if (normalized === "::" || normalized === "::1")
|
|
316
|
+
return true;
|
|
317
|
+
if (normalized.startsWith("::ffff:"))
|
|
318
|
+
return true;
|
|
319
|
+
if (normalized.startsWith("fc") || normalized.startsWith("fd"))
|
|
320
|
+
return true;
|
|
321
|
+
if (/^fe[89ab]/.test(normalized))
|
|
322
|
+
return true;
|
|
323
|
+
const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
324
|
+
if (mapped)
|
|
325
|
+
return isPrivateOrLoopbackIpv4(mapped[1]);
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
function blockedWebhookHostnameReason(hostname) {
|
|
329
|
+
const normalized = normalizeWebhookHostname(hostname);
|
|
330
|
+
if (normalized === "localhost" ||
|
|
331
|
+
normalized.endsWith(".local") ||
|
|
332
|
+
normalized.endsWith(".internal") ||
|
|
333
|
+
/^(0x[0-9a-f]+|0\d+)$/i.test(normalized) ||
|
|
334
|
+
/^\d{8,10}$/.test(normalized)) {
|
|
335
|
+
return `webhook hostname "${hostname}" is a private or loopback address.`;
|
|
336
|
+
}
|
|
337
|
+
if (isPrivateOrLoopbackAddress(normalized)) {
|
|
338
|
+
return `webhook hostname "${hostname}" is a private or loopback address.`;
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
export function validateCustomWebhookUrl(webhook) {
|
|
343
|
+
let parsed;
|
|
344
|
+
try {
|
|
345
|
+
parsed = new URL(webhook.trim());
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return "webhook is not a valid URL.";
|
|
349
|
+
}
|
|
350
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
351
|
+
return "webhook must be an http:// or https:// URL.";
|
|
352
|
+
}
|
|
353
|
+
return blockedWebhookHostnameReason(parsed.hostname);
|
|
354
|
+
}
|
|
355
|
+
async function validateWebhookAtExecution(webhook) {
|
|
356
|
+
let parsed;
|
|
357
|
+
try {
|
|
358
|
+
parsed = new URL(webhook);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return "webhook is not a valid URL.";
|
|
362
|
+
}
|
|
363
|
+
const literalBlock = blockedWebhookHostnameReason(parsed.hostname);
|
|
364
|
+
if (literalBlock)
|
|
365
|
+
return literalBlock;
|
|
366
|
+
try {
|
|
367
|
+
const records = await lookup(parsed.hostname, { all: true, verbatim: true });
|
|
368
|
+
if (records.some((record) => isPrivateOrLoopbackAddress(record.address))) {
|
|
369
|
+
return `webhook hostname "${parsed.hostname}" resolved to a private or loopback address.`;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
debugLog(`validateWebhookAtExecution lookup failed for ${parsed.hostname}: ${errorMessage(err)}`);
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
286
377
|
const DEFAULT_CUSTOM_HOOK_TIMEOUT = 5000;
|
|
287
378
|
const HOOK_TIMEOUT_MS = parseInt(process.env.PHREN_HOOK_TIMEOUT_MS || '14000', 10);
|
|
288
379
|
const HOOK_ERROR_LOG_MAX_LINES = 1000;
|
|
@@ -337,12 +428,21 @@ export function runCustomHooks(phrenPath, event, env = {}) {
|
|
|
337
428
|
if (hook.secret) {
|
|
338
429
|
headers["X-Phren-Signature"] = `sha256=${createHmac("sha256", hook.secret).update(payload).digest("hex")}`;
|
|
339
430
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
431
|
+
void validateWebhookAtExecution(hook.webhook)
|
|
432
|
+
.then((blockReason) => {
|
|
433
|
+
if (blockReason) {
|
|
434
|
+
const message = `${event}: skipped webhook ${hook.webhook}: ${blockReason}`;
|
|
435
|
+
debugLog(`runCustomHooks webhook: ${message}`);
|
|
436
|
+
appendHookErrorLog(phrenPath, event, message);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
return fetch(hook.webhook, {
|
|
440
|
+
method: "POST",
|
|
441
|
+
headers,
|
|
442
|
+
body: payload,
|
|
443
|
+
redirect: "manual",
|
|
444
|
+
signal: AbortSignal.timeout(hook.timeout ?? DEFAULT_CUSTOM_HOOK_TIMEOUT),
|
|
445
|
+
});
|
|
346
446
|
})
|
|
347
447
|
.catch((err) => {
|
|
348
448
|
const message = `${event}: ${hook.webhook}: ${errorMessage(err)}`;
|
|
@@ -357,7 +457,7 @@ export function runCustomHooks(phrenPath, event, env = {}) {
|
|
|
357
457
|
});
|
|
358
458
|
continue;
|
|
359
459
|
}
|
|
360
|
-
const cmdErr =
|
|
460
|
+
const cmdErr = validateCustomHookCommand(hook.command);
|
|
361
461
|
if (cmdErr) {
|
|
362
462
|
const message = `${event}: skipped hook (re-validation failed): ${cmdErr}`;
|
|
363
463
|
debugLog(`runCustomHooks: ${message}`);
|
package/mcp/dist/mcp-data.js
CHANGED
|
@@ -2,9 +2,9 @@ import { mcpResponse } from "./mcp-types.js";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
|
-
import { isValidProjectName, errorMessage } from "./utils.js";
|
|
5
|
+
import { isValidProjectName, errorMessage, safeProjectPath } from "./utils.js";
|
|
6
6
|
import { readFindings, readTasks, resolveTaskFilePath, TASKS_FILENAME } from "./data-access.js";
|
|
7
|
-
import { debugLog, findProjectNameCaseInsensitive, normalizeProjectNameForCreate } from "./shared.js";
|
|
7
|
+
import { debugLog, findArchivedProjectNameCaseInsensitive, findProjectNameCaseInsensitive, normalizeProjectNameForCreate } from "./shared.js";
|
|
8
8
|
const importPayloadSchema = z.object({
|
|
9
9
|
project: z.string(),
|
|
10
10
|
overwrite: z.boolean().optional(),
|
|
@@ -36,18 +36,19 @@ export function register(server, ctx) {
|
|
|
36
36
|
}, async ({ project }) => {
|
|
37
37
|
if (!isValidProjectName(project))
|
|
38
38
|
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
39
|
-
const projectDir =
|
|
40
|
-
if (!fs.existsSync(projectDir))
|
|
39
|
+
const projectDir = safeProjectPath(phrenPath, project);
|
|
40
|
+
if (!projectDir || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
|
|
41
41
|
return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
|
|
42
|
+
}
|
|
42
43
|
const exported = { project, exportedAt: new Date().toISOString(), version: 1 };
|
|
43
|
-
const summaryPath =
|
|
44
|
-
if (fs.existsSync(summaryPath))
|
|
44
|
+
const summaryPath = safeProjectPath(projectDir, "summary.md");
|
|
45
|
+
if (summaryPath && fs.existsSync(summaryPath))
|
|
45
46
|
exported.summary = fs.readFileSync(summaryPath, "utf8");
|
|
46
47
|
const learningsResult = readFindings(phrenPath, project);
|
|
47
48
|
if (learningsResult.ok)
|
|
48
49
|
exported.learnings = learningsResult.data;
|
|
49
|
-
const findingsPath =
|
|
50
|
-
if (fs.existsSync(findingsPath))
|
|
50
|
+
const findingsPath = safeProjectPath(projectDir, "FINDINGS.md");
|
|
51
|
+
if (findingsPath && fs.existsSync(findingsPath))
|
|
51
52
|
exported.findingsRaw = fs.readFileSync(findingsPath, "utf8");
|
|
52
53
|
const taskResult = readTasks(phrenPath, project);
|
|
53
54
|
if (taskResult.ok) {
|
|
@@ -57,8 +58,8 @@ export function register(server, ctx) {
|
|
|
57
58
|
if (taskRawPath && fs.existsSync(taskRawPath))
|
|
58
59
|
exported.taskRaw = fs.readFileSync(taskRawPath, "utf8");
|
|
59
60
|
}
|
|
60
|
-
const claudePath =
|
|
61
|
-
if (fs.existsSync(claudePath))
|
|
61
|
+
const claudePath = safeProjectPath(projectDir, "CLAUDE.md");
|
|
62
|
+
if (claudePath && fs.existsSync(claudePath))
|
|
62
63
|
exported.claudeMd = fs.readFileSync(claudePath, "utf8");
|
|
63
64
|
return mcpResponse({ ok: true, message: `Exported project "${project}".`, data: exported });
|
|
64
65
|
});
|
|
@@ -85,7 +86,7 @@ export function register(server, ctx) {
|
|
|
85
86
|
}
|
|
86
87
|
const parsed = parsedResult.data;
|
|
87
88
|
// Warn about unknown fields silently discarded by .passthrough()
|
|
88
|
-
const knownTopLevel = new Set(["project", "overwrite", "summary", "claudeMd", "learnings", "task", "exportedAt", "version", "findingsRaw"]);
|
|
89
|
+
const knownTopLevel = new Set(["project", "overwrite", "summary", "claudeMd", "learnings", "task", "taskRaw", "exportedAt", "version", "findingsRaw"]);
|
|
89
90
|
const unknownFields = Object.keys(decoded).filter(k => !knownTopLevel.has(k));
|
|
90
91
|
if (unknownFields.length > 0) {
|
|
91
92
|
debugLog(`import_project: unknown fields will be ignored: ${unknownFields.join(", ")}`);
|
|
@@ -101,7 +102,10 @@ export function register(server, ctx) {
|
|
|
101
102
|
error: `Project "${existingProject}" already exists with different casing. Refusing to import "${projectName}" because it would split the same project on case-sensitive filesystems.`,
|
|
102
103
|
});
|
|
103
104
|
}
|
|
104
|
-
const projectDir =
|
|
105
|
+
const projectDir = safeProjectPath(phrenPath, projectName);
|
|
106
|
+
if (!projectDir) {
|
|
107
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${parsed.project}"` });
|
|
108
|
+
}
|
|
105
109
|
const overwrite = parsed.overwrite === true;
|
|
106
110
|
if (fs.existsSync(projectDir) && !overwrite) {
|
|
107
111
|
return mcpResponse({
|
|
@@ -112,6 +116,7 @@ export function register(server, ctx) {
|
|
|
112
116
|
const stagingRoot = fs.mkdtempSync(path.join(phrenPath, `.phren-import-${projectName}-`));
|
|
113
117
|
const stagedProjectDir = path.join(stagingRoot, projectName);
|
|
114
118
|
const imported = [];
|
|
119
|
+
let backupDir = null;
|
|
115
120
|
const cleanupDir = (dir) => {
|
|
116
121
|
if (fs.existsSync(dir))
|
|
117
122
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -181,7 +186,7 @@ export function register(server, ctx) {
|
|
|
181
186
|
fs.writeFileSync(path.join(stagedProjectDir, TASKS_FILENAME), taskContent);
|
|
182
187
|
imported.push(TASKS_FILENAME);
|
|
183
188
|
}
|
|
184
|
-
|
|
189
|
+
backupDir = overwrite ? path.join(phrenPath, `${projectName}.import-backup-${Date.now()}`) : null;
|
|
185
190
|
try {
|
|
186
191
|
if (overwrite && fs.existsSync(projectDir)) {
|
|
187
192
|
fs.renameSync(projectDir, backupDir);
|
|
@@ -222,22 +227,14 @@ export function register(server, ctx) {
|
|
|
222
227
|
}
|
|
223
228
|
catch { /* best-effort */ }
|
|
224
229
|
}
|
|
225
|
-
else {
|
|
226
|
-
// Find the backup dir that was created earlier
|
|
230
|
+
else if (backupDir) {
|
|
227
231
|
try {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
else if (fs.existsSync(backupPath)) {
|
|
235
|
-
// Active dir exists — remove imported dir then restore backup
|
|
236
|
-
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
237
|
-
fs.renameSync(backupPath, projectDir);
|
|
238
|
-
}
|
|
239
|
-
break;
|
|
240
|
-
}
|
|
232
|
+
if (fs.existsSync(backupDir) && !fs.existsSync(projectDir)) {
|
|
233
|
+
fs.renameSync(backupDir, projectDir);
|
|
234
|
+
}
|
|
235
|
+
else if (fs.existsSync(backupDir)) {
|
|
236
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
237
|
+
fs.renameSync(backupDir, projectDir);
|
|
241
238
|
}
|
|
242
239
|
}
|
|
243
240
|
catch (err) {
|
|
@@ -252,13 +249,9 @@ export function register(server, ctx) {
|
|
|
252
249
|
});
|
|
253
250
|
}
|
|
254
251
|
// Backup is only deleted after successful rebuild so we can restore on failure
|
|
255
|
-
if (
|
|
252
|
+
if (backupDir && fs.existsSync(backupDir)) {
|
|
256
253
|
try {
|
|
257
|
-
|
|
258
|
-
if (entry.startsWith(`${projectName}.import-backup-`)) {
|
|
259
|
-
fs.rmSync(path.join(phrenPath, entry), { recursive: true, force: true });
|
|
260
|
-
}
|
|
261
|
-
}
|
|
254
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
262
255
|
}
|
|
263
256
|
catch (err) {
|
|
264
257
|
if ((process.env.PHREN_DEBUG))
|
|
@@ -284,9 +277,13 @@ export function register(server, ctx) {
|
|
|
284
277
|
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
285
278
|
return withWriteQueue(async () => {
|
|
286
279
|
const activeProject = findProjectNameCaseInsensitive(phrenPath, project);
|
|
287
|
-
const
|
|
288
|
-
const archiveDir = path.join(phrenPath, `${project}.archived`);
|
|
280
|
+
const archivedProject = findArchivedProjectNameCaseInsensitive(phrenPath, project);
|
|
289
281
|
if (action === "archive") {
|
|
282
|
+
if (!activeProject) {
|
|
283
|
+
return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
|
|
284
|
+
}
|
|
285
|
+
const projectDir = path.join(phrenPath, activeProject);
|
|
286
|
+
const archiveDir = path.join(phrenPath, `${activeProject}.archived`);
|
|
290
287
|
if (!fs.existsSync(projectDir)) {
|
|
291
288
|
return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
|
|
292
289
|
}
|
|
@@ -311,11 +308,13 @@ export function register(server, ctx) {
|
|
|
311
308
|
if (activeProject) {
|
|
312
309
|
return mcpResponse({ ok: false, error: `Project "${activeProject}" already exists as an active project.` });
|
|
313
310
|
}
|
|
314
|
-
if (!
|
|
311
|
+
if (!archivedProject) {
|
|
315
312
|
const entries = fs.readdirSync(phrenPath).filter((e) => e.endsWith(".archived"));
|
|
316
313
|
const available = entries.map((e) => e.replace(/\.archived$/, ""));
|
|
317
314
|
return mcpResponse({ ok: false, error: `No archive found for "${project}".`, data: { availableArchives: available } });
|
|
318
315
|
}
|
|
316
|
+
const projectDir = path.join(phrenPath, archivedProject);
|
|
317
|
+
const archiveDir = path.join(phrenPath, `${archivedProject}.archived`);
|
|
319
318
|
fs.renameSync(archiveDir, projectDir);
|
|
320
319
|
try {
|
|
321
320
|
await rebuildIndex();
|
|
@@ -326,8 +325,8 @@ export function register(server, ctx) {
|
|
|
326
325
|
}
|
|
327
326
|
return mcpResponse({
|
|
328
327
|
ok: true,
|
|
329
|
-
message: `Unarchived project "${
|
|
330
|
-
data: { project, path: projectDir },
|
|
328
|
+
message: `Unarchived project "${archivedProject}". It is now active again.`,
|
|
329
|
+
data: { project: archivedProject, path: projectDir },
|
|
331
330
|
});
|
|
332
331
|
}); // end withWriteQueue
|
|
333
332
|
});
|
package/mcp/dist/mcp-graph.js
CHANGED
|
@@ -268,8 +268,8 @@ export function register(server, ctx) {
|
|
|
268
268
|
existing = JSON.parse(fs.readFileSync(manualLinksPath, "utf8"));
|
|
269
269
|
}
|
|
270
270
|
catch (err) {
|
|
271
|
-
|
|
272
|
-
|
|
271
|
+
process.stderr.write(`[phren] link_findings manualLinksRead: manual-links.json is malformed — aborting to avoid data loss: ${errorMessage(err)}\n`);
|
|
272
|
+
throw err;
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
const newEntry = { entity: fragmentName, entityType: resolvedFragmentType, sourceDoc, relType };
|
package/mcp/dist/mcp-hooks.js
CHANGED
|
@@ -3,37 +3,12 @@ import { z } from "zod";
|
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
import { readInstallPreferences, updateInstallPreferences } from "./init-preferences.js";
|
|
6
|
-
import { readCustomHooks, getHookTarget, HOOK_EVENT_VALUES } from "./hooks.js";
|
|
6
|
+
import { readCustomHooks, getHookTarget, HOOK_EVENT_VALUES, validateCustomHookCommand, validateCustomWebhookUrl } from "./hooks.js";
|
|
7
7
|
import { hookConfigPath } from "./shared.js";
|
|
8
8
|
import { PROJECT_HOOK_EVENTS, isProjectHookEnabled, readProjectConfig, writeProjectHookConfig } from "./project-config.js";
|
|
9
9
|
import { isValidProjectName } from "./utils.js";
|
|
10
10
|
const HOOK_TOOLS = ["claude", "copilot", "cursor", "codex"];
|
|
11
11
|
const VALID_CUSTOM_EVENTS = HOOK_EVENT_VALUES;
|
|
12
|
-
/**
|
|
13
|
-
* Validate a custom hook command at registration time.
|
|
14
|
-
* Rejects obviously dangerous patterns to reduce confused-deputy risk
|
|
15
|
-
* if install-preferences.json is ever compromised.
|
|
16
|
-
* Returns an error string, or null if valid.
|
|
17
|
-
*/
|
|
18
|
-
function validateHookCommand(command) {
|
|
19
|
-
const trimmed = command.trim();
|
|
20
|
-
if (!trimmed)
|
|
21
|
-
return "Command cannot be empty.";
|
|
22
|
-
if (trimmed.length > 1000)
|
|
23
|
-
return "Command too long (max 1000 characters).";
|
|
24
|
-
// Reject shell metacharacters that allow injection or arbitrary execution
|
|
25
|
-
// when the command is later run via `sh -c`.
|
|
26
|
-
if (/[`$(){}&|;<>\n\r#]/.test(trimmed)) {
|
|
27
|
-
return "Command contains disallowed shell characters: ` $ ( ) { } & | ; < >";
|
|
28
|
-
}
|
|
29
|
-
// eval and source can execute arbitrary code
|
|
30
|
-
if (/\b(eval|source)\b/.test(trimmed))
|
|
31
|
-
return "eval and source are not permitted in hook commands.";
|
|
32
|
-
// Command must start with a word character, path, or quoted string
|
|
33
|
-
if (!/^[\w./~"'"]/.test(trimmed))
|
|
34
|
-
return "Command must begin with an executable name or path.";
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
12
|
function normalizeHookTool(input) {
|
|
38
13
|
if (!input)
|
|
39
14
|
return null;
|
|
@@ -193,47 +168,13 @@ export function register(server, ctx) {
|
|
|
193
168
|
let newHook;
|
|
194
169
|
if (webhook) {
|
|
195
170
|
const trimmed = webhook.trim();
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// Reject private/loopback hostnames to prevent SSRF
|
|
200
|
-
try {
|
|
201
|
-
const { hostname } = new URL(trimmed);
|
|
202
|
-
const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
203
|
-
const ssrfBlocked = h === "localhost" ||
|
|
204
|
-
// IPv4 private/loopback ranges
|
|
205
|
-
/^127\./.test(h) ||
|
|
206
|
-
/^10\./.test(h) ||
|
|
207
|
-
/^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
|
|
208
|
-
/^192\.168\./.test(h) ||
|
|
209
|
-
/^169\.254\./.test(h) ||
|
|
210
|
-
// IPv6 loopback
|
|
211
|
-
h === "::1" ||
|
|
212
|
-
// IPv6 ULA (fc00::/7 covers fc:: and fd::)
|
|
213
|
-
h.startsWith("fc") ||
|
|
214
|
-
h.startsWith("fd") ||
|
|
215
|
-
// IPv6 link-local (fe80::/10)
|
|
216
|
-
h.startsWith("fe80:") ||
|
|
217
|
-
// IPv4-mapped IPv6 (::ffff:10.x.x.x, ::ffff:127.x.x.x, etc.)
|
|
218
|
-
/^::ffff:/i.test(h) ||
|
|
219
|
-
// Raw numeric IPv4 forms not normalized by all URL parsers:
|
|
220
|
-
// decimal (2130706433), hex (0x7f000001), octal (0177.0.0.1 prefix)
|
|
221
|
-
/^(0x[0-9a-f]+|0\d+)$/i.test(h) ||
|
|
222
|
-
// Pure decimal integer that encodes an IPv4 address (8+ digits covers 0.0.0.0+)
|
|
223
|
-
/^\d{8,10}$/.test(h) ||
|
|
224
|
-
h.endsWith(".local") ||
|
|
225
|
-
h.endsWith(".internal");
|
|
226
|
-
if (ssrfBlocked) {
|
|
227
|
-
return mcpResponse({ ok: false, error: `webhook hostname "${hostname}" is a private or loopback address.` });
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
catch {
|
|
231
|
-
return mcpResponse({ ok: false, error: "webhook is not a valid URL." });
|
|
232
|
-
}
|
|
171
|
+
const webhookErr = validateCustomWebhookUrl(trimmed);
|
|
172
|
+
if (webhookErr)
|
|
173
|
+
return mcpResponse({ ok: false, error: webhookErr });
|
|
233
174
|
newHook = { event, webhook: trimmed, ...(secret ? { secret } : {}), ...(timeout !== undefined ? { timeout } : {}) };
|
|
234
175
|
}
|
|
235
176
|
else {
|
|
236
|
-
const cmdErr =
|
|
177
|
+
const cmdErr = validateCustomHookCommand(command);
|
|
237
178
|
if (cmdErr)
|
|
238
179
|
return mcpResponse({ ok: false, error: cmdErr });
|
|
239
180
|
newHook = { event, command: command, ...(timeout !== undefined ? { timeout } : {}) };
|