@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.
@@ -1,6 +1,6 @@
1
1
  export const cliManifest = {
2
2
  surface: "cli",
3
- version: "0.0.15",
3
+ version: "0.0.16",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: true, handler: "cli-actions.ts:handleAddFinding" },
@@ -1,6 +1,6 @@
1
1
  export const mcpManifest = {
2
2
  surface: "mcp",
3
- version: "0.0.15",
3
+ version: "0.0.16",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: true, handler: "index.ts:add_finding" },
@@ -1,6 +1,6 @@
1
1
  export const vscodeManifest = {
2
2
  surface: "vscode",
3
- version: "0.0.15",
3
+ version: "0.0.16",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: true, handler: "extension.ts:phren.addFinding" },
@@ -1,6 +1,6 @@
1
1
  export const webUiManifest = {
2
2
  surface: "web-ui",
3
- version: "0.0.15",
3
+ version: "0.0.16",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: false, reason: "Web UI is read-only for findings (review queue only)" },
@@ -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 { /* fresh start */ }
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 { appendAuditLog, getPhrenPath, readRootManifest, } from "./shared.js";
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
- if (fs.existsSync(projectDir)) {
816
- console.error(`Project "${name}" already exists as an active project.`);
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="${escapedPhren}" node "${escapedEntry}" hook-session-start`,
89
- userPromptSubmit: `PHREN_PATH="${escapedPhren}" node "${escapedEntry}" hook-prompt`,
90
- stop: `PHREN_PATH="${escapedPhren}" node "${escapedEntry}" hook-stop`,
91
- hookTool: `PHREN_PATH="${escapedPhren}" node "${escapedEntry}" hook-tool`,
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="${escapedPhren}" npx -y ${packageSpec} hook-session-start`,
105
- userPromptSubmit: `PHREN_PATH="${escapedPhren}" npx -y ${packageSpec} hook-prompt`,
106
- stop: `PHREN_PATH="${escapedPhren}" npx -y ${packageSpec} hook-stop`,
107
- hookTool: `PHREN_PATH="${escapedPhren}" npx -y ${packageSpec} hook-tool`,
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="${tool}" ${command}`;
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="${escapedBinary}"
148
- PHREN_PATH="\${PHREN_PATH:-${escapedPhren}}"
149
- ENTRY_SCRIPT="${escapedEntry}"
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
- /** Re-validate a command hook at execution time (mirrors mcp-hooks.ts validateHookCommand). */
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 (/[`$(){}&|;<>]/.test(trimmed))
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
- fetch(hook.webhook, {
341
- method: "POST",
342
- headers,
343
- body: payload,
344
- redirect: "manual",
345
- signal: AbortSignal.timeout(hook.timeout ?? DEFAULT_CUSTOM_HOOK_TIMEOUT),
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 = validateCommandAtExecution(hook.command);
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}`);
@@ -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 = path.join(phrenPath, project);
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 = path.join(projectDir, "summary.md");
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 = path.join(projectDir, "FINDINGS.md");
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 = path.join(projectDir, "CLAUDE.md");
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 = path.join(phrenPath, projectName);
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
- const backupDir = overwrite ? path.join(phrenPath, `${projectName}.import-backup-${Date.now()}`) : null;
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
- for (const entry of fs.readdirSync(phrenPath)) {
229
- if (entry.startsWith(`${projectName}.import-backup-`)) {
230
- const backupPath = path.join(phrenPath, entry);
231
- if (fs.existsSync(backupPath) && !fs.existsSync(projectDir)) {
232
- fs.renameSync(backupPath, projectDir);
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 (overwrite) {
252
+ if (backupDir && fs.existsSync(backupDir)) {
256
253
  try {
257
- for (const entry of fs.readdirSync(phrenPath)) {
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 projectDir = path.join(phrenPath, project);
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 (!fs.existsSync(archiveDir)) {
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 "${project}". It is now active again.`,
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
  });
@@ -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
- if (process.env.PHREN_DEBUG)
272
- process.stderr.write(`[phren] link_findings manualLinksRead: ${errorMessage(err)}\n`);
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 };
@@ -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
- if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
197
- return mcpResponse({ ok: false, error: "webhook must be an http:// or https:// URL." });
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 = validateHookCommand(command);
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 } : {}) };