From 36b7f1bfc324f0ea1a0d9e2e64a69fdd2bea7516 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:30:32 -0400 Subject: [PATCH 1/7] feat(cli): validate env vars up front in init and configure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move server-side validation earlier for values the CLI actively prompts for, so bad inputs surface as CLI errors instead of a crash-looping container: - PUBLIC_URL: reject credentials (user:password@) — mirrors server.ts urlHasCredentials check - VAULT_PATH: reject glob characters (*, ?, [) — mirrors server.ts GLOB_CHARS check - MEMORY_DIR (askFolder): reject path traversal (..) and absolute paths (/) — mirrors config.ts vaultFolderName Zod refinements - DAILY_NOTES_FOLDER: same traversal/absolute guard via validate callback on the optionalText setting - DAILY_NOTES_FORMAT: traversal + boundary separator guard, plus digit-outside-brackets rejection (catches "2024-MM-DD" vs "YYYY-MM-DD") Extracts validatePublicUrl as a pure function (exported for direct testing), adds validate? callback to OptionalSettingBase for per-setting validation on generic prompt types. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/init.test.ts | 70 +++++++- cli/src/__tests__/optional-settings.test.ts | 183 ++++++++++++++++++++ cli/src/__tests__/vault.test.ts | 27 +++ cli/src/init.ts | 57 ++++-- cli/src/optional-settings.ts | 56 +++++- cli/src/vault.ts | 5 + 6 files changed, 375 insertions(+), 23 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 62036e374..b232d1c90 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -9,7 +9,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { describe, expect, it, onTestFinished, vi } from "vitest" -import { runInit } from "../init.js" +import { runInit, validatePublicUrl } from "../init.js" import { pollHealth } from "../docker.js" import { buildDockerNotInstalledMessage } from "../messages.js" @@ -1426,3 +1426,71 @@ describe("runInit health-timeout returns starting status", () => { expect(scripted.prints[0]).not.toContain("The server is running.") }) }) + +describe("validatePublicUrl", () => { + it("accepts a valid https URL", () => { + expect(validatePublicUrl("https://vault.example.com")).toEqual({ + kind: "ok", + url: "https://vault.example.com", + }) + }) + + it("accepts a valid http URL with port", () => { + expect(validatePublicUrl("http://203.0.113.10:8000")).toEqual({ + kind: "ok", + url: "http://203.0.113.10:8000", + }) + }) + + it("strips a trailing slash", () => { + expect(validatePublicUrl("https://vault.example.com/")).toEqual({ + kind: "ok", + url: "https://vault.example.com", + }) + }) + + it("rejects a URL with username and password", () => { + expect(validatePublicUrl("https://user:pass@vault.example.com")).toEqual({ + kind: "error", + message: "PUBLIC_URL must not contain credentials (user:password@).", + }) + }) + + it("rejects a URL with username only", () => { + expect(validatePublicUrl("https://user@vault.example.com")).toEqual({ + kind: "error", + message: "PUBLIC_URL must not contain credentials (user:password@).", + }) + }) + + it("rejects a URL with password only", () => { + expect(validatePublicUrl("https://:pass@vault.example.com")).toEqual({ + kind: "error", + message: "PUBLIC_URL must not contain credentials (user:password@).", + }) + }) + + it("rejects a non-http URL", () => { + expect(validatePublicUrl("ws://vault.example.com")).toEqual({ + kind: "error", + message: + "PUBLIC_URL must be a full http:// or https:// URL (e.g. https://vault.example.com).", + }) + }) + + it("rejects a URL with a trailing /mcp path", () => { + expect(validatePublicUrl("https://vault.example.com/mcp")).toEqual({ + kind: "error", + message: + "Leave /mcp off PUBLIC_URL — it's the base URL and the server adds /mcp itself (e.g. https://vault.example.com).", + }) + }) + + it("rejects invalid syntax", () => { + expect(validatePublicUrl("not-a-url")).toEqual({ + kind: "error", + message: + "PUBLIC_URL must be a full http:// or https:// URL (e.g. https://vault.example.com).", + }) + }) +}) diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 9283c434d..24f2d79b9 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -630,3 +630,186 @@ describe("askOptionalSettings per-setting prompts", () => { ) }) }) + +describe("askFolder validation", () => { + it("rejects path traversal", async () => { + const scripted = createScriptedPrompts([ + // chooser: pick MEMORY_DIR + ["MEMORY_DIR"], + // first answer: traversal → rejected, re-prompted + "../secret", + // second answer: valid + "My Notes", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "MEMORY_DIR=About Me\n" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([ + "Path traversal (..) is not allowed in folder names.", + ]) + expect(overrides).toEqual({ MEMORY_DIR: "My Notes" }) + }) + + it("rejects absolute paths", async () => { + const scripted = createScriptedPrompts([ + ["MEMORY_DIR"], + "/var/data", + "My Notes", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "MEMORY_DIR=About Me\n" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([ + "Absolute paths are not allowed — use a vault-relative folder name.", + ]) + expect(overrides).toEqual({ MEMORY_DIR: "My Notes" }) + }) +}) + +describe("DAILY_NOTES_FOLDER validate callback", () => { + it("rejects path traversal", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FOLDER"], + "../etc", + "Journal", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([ + "Path traversal (..) is not allowed in folder names.", + ]) + expect(overrides).toEqual({ DAILY_NOTES_FOLDER: "Journal" }) + }) + + it("rejects absolute paths", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FOLDER"], + "/var/notes", + "Journal", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([ + "Absolute paths are not allowed — use a vault-relative folder name.", + ]) + expect(overrides).toEqual({ DAILY_NOTES_FOLDER: "Journal" }) + }) +}) + +describe("DAILY_NOTES_FORMAT validate callback", () => { + it("rejects path traversal", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FORMAT"], + "../YYYY-MM-DD", + "YYYY-MM-DD", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([ + "Date format must not contain path traversal (..).", + ]) + expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) + }) + + it("rejects a leading path separator", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FORMAT"], + "/YYYY-MM-DD", + "YYYY-MM-DD", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([ + "Date format must not start with a path separator.", + ]) + expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) + }) + + it("rejects a trailing path separator", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FORMAT"], + "YYYY-MM-DD/", + "YYYY-MM-DD", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([ + "Date format must not end with a path separator.", + ]) + expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) + }) + + it("rejects digits outside bracket escapes", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FORMAT"], + "2024-MM-DD", + "YYYY-MM-DD", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([ + "Date format should use Moment tokens (YYYY, MM, DD), not digits — wrap literal text in [...] brackets.", + ]) + expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) + }) + + it("accepts digits inside bracket escapes", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FORMAT"], + "YYYY-MM-DD [Day 1]", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([]) + expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD [Day 1]" }) + }) + + it("accepts a valid format without digits", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FORMAT"], + "YYYY/MM/DD", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([]) + expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY/MM/DD" }) + }) +}) diff --git a/cli/src/__tests__/vault.test.ts b/cli/src/__tests__/vault.test.ts index 977869d67..ba167eb87 100644 --- a/cli/src/__tests__/vault.test.ts +++ b/cli/src/__tests__/vault.test.ts @@ -41,6 +41,33 @@ describe("validateVaultPath", () => { }) }) + it("rejects a path containing an asterisk glob character", () => { + const validation = validateVaultPath("/path/to/my*vault") + + expect(validation).toEqual({ + kind: "error", + message: "Vault path must not contain glob characters (*, ?, [).", + }) + }) + + it("rejects a path containing a question-mark glob character", () => { + const validation = validateVaultPath("/path/to/my?vault") + + expect(validation).toEqual({ + kind: "error", + message: "Vault path must not contain glob characters (*, ?, [).", + }) + }) + + it("rejects a path containing a bracket glob character", () => { + const validation = validateVaultPath("/path/to/my[vault") + + expect(validation).toEqual({ + kind: "error", + message: "Vault path must not contain glob characters (*, ?, [).", + }) + }) + it("returns an error when the path does not exist", () => { const missingPath = join(tmpdir(), "vault-cortex-test-does-not-exist") diff --git a/cli/src/init.ts b/cli/src/init.ts index eec56330f..ee0592590 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -144,6 +144,40 @@ const parseHttpUrl = (value: string): URL | null => { } } +export type PublicUrlValidation = + { kind: "ok"; url: string } | { kind: "error"; message: string } + +/** Validates a PUBLIC_URL value: must be http(s), no credentials, no /mcp suffix. */ +export const validatePublicUrl = (input: string): PublicUrlValidation => { + const trimmed = input.trim() + const url = parseHttpUrl(trimmed) + if (!url) { + return { + kind: "error", + message: + "PUBLIC_URL must be a full http:// or https:// URL (e.g. https://vault.example.com).", + } + } + if (url.username || url.password) { + return { + kind: "error", + message: "PUBLIC_URL must not contain credentials (user:password@).", + } + } + if (TRAILING_MCP_PATH.test(url.pathname)) { + return { + kind: "error", + message: + "Leave /mcp off PUBLIC_URL — it's the base URL and the server adds /mcp itself (e.g. https://vault.example.com).", + } + } + // Store the input as typed, trimming only a trailing slash so the connect + // URL is `${base}/mcp`, never `${base}//mcp`. URL's own normalization is + // unusable here: `.href` adds a trailing slash and `.origin` drops the path, + // so neither round-trips a reverse-proxy subpath like https://host/api. + return { kind: "ok", url: trimmed.replace(/\/+$/, "") } +} + /** Re-prompts until the answer is a valid base http(s) URL (no /mcp path). */ const askPublicUrl = async (prompts: Prompts): Promise => { const answer = await prompts.text( @@ -152,27 +186,12 @@ const askPublicUrl = async (prompts: Prompts): Promise => { placeholder: "https://vault.example.com or http://203.0.113.10:8000", }, ) - const trimmed = answer.trim() - const url = parseHttpUrl(trimmed) - if (url === null) { - prompts.error( - "PUBLIC_URL must be a full http:// or https:// URL (e.g. https://vault.example.com).", - ) + const result = validatePublicUrl(answer) + if (result.kind === "error") { + prompts.error(result.message) return askPublicUrl(prompts) } - // Reject a re-included endpoint path instead of stripping it silently — - // PUBLIC_URL is the base origin and the server adds /mcp itself. - if (TRAILING_MCP_PATH.test(url.pathname)) { - prompts.error( - "Leave /mcp off PUBLIC_URL — it's the base URL and the server adds /mcp itself (e.g. https://vault.example.com).", - ) - return askPublicUrl(prompts) - } - // Store the input as typed, trimming only a trailing slash so the connect - // URL is `${base}/mcp`, never `${base}//mcp`. URL's own normalization is - // unusable here: `.href` adds a trailing slash and `.origin` drops the path, - // so neither round-trips a reverse-proxy subpath like https://host/api. - return trimmed.replace(/\/+$/, "") + return result.url } /** Re-prompts until non-empty. */ diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 6cd9f017a..74d3ed757 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -14,6 +14,8 @@ type OptionalSettingBase = { requiresToggle?: string /** Only offered in remote-mode flows (absent = offered in both modes). */ remoteOnly?: true + /** Rejects a user-entered value — returns an error message, or undefined to accept. */ + validate?: (value: string) => string | undefined } /** @@ -50,6 +52,9 @@ type OptionalSetting = defaultValue: string }) +/** Matches Moment.js [...] literal escape groups, splitting format spans from literal content. */ +const MOMENT_BRACKET_ESCAPE = /\[([^\]]*)\]/g + // The curated prompt set — settings users most often want without reading // .env comments. Everything else stays documented-only in the generated // optional block, deliberately: every extra prompt costs init flow length. @@ -74,6 +79,13 @@ const OPTIONAL_SETTINGS: OptionalSetting[] = [ label: "Daily notes folder", question: "Vault folder for daily notes:", placeholder: "blank = use your vault's daily notes settings", + validate: (value) => { + if (value.includes("..")) + return "Path traversal (..) is not allowed in folder names." + if (value.startsWith("/")) + return "Absolute paths are not allowed — use a vault-relative folder name." + return undefined + }, }, { kind: "optionalText", @@ -81,6 +93,23 @@ const OPTIONAL_SETTINGS: OptionalSetting[] = [ label: "Daily notes format", question: "Filename date format for daily notes (e.g. YYYY-MM-DD):", placeholder: "blank = use your vault's daily notes settings", + validate: (value) => { + if (value.includes("..")) + return "Date format must not contain path traversal (..)." + if (value.startsWith("/")) + return "Date format must not start with a path separator." + if (value.endsWith("/")) + return "Date format must not end with a path separator." + // Moment format tokens are all letters — digits outside of [...] + // bracket escapes are almost always a mistake. + const formatSegments = value.split(MOMENT_BRACKET_ESCAPE) + const hasDigitsInFormat = formatSegments.some( + (segment, index) => index % 2 === 0 && /\d/.test(segment), + ) + if (hasDigitsInFormat) + return "Date format should use Moment tokens (YYYY, MM, DD), not digits — wrap literal text in [...] brackets." + return undefined + }, }, { kind: "toggle", @@ -290,7 +319,19 @@ const askFolder = async ( placeholder: defaultValue, }) ).trim() - if (answer !== "") return answer + if (answer !== "") { + if (answer.includes("..")) { + prompts.error("Path traversal (..) is not allowed in folder names.") + return askFolder(params, prompts) + } + if (answer.startsWith("/")) { + prompts.error( + "Absolute paths are not allowed — use a vault-relative folder name.", + ) + return askFolder(params, prompts) + } + return answer + } prompts.error("The folder name can't be empty.") return askFolder(params, prompts) } @@ -366,8 +407,8 @@ const askSettingValue = async ( }, prompts, ) - case "optionalText": - return askOptionalText( + case "optionalText": { + const value = await askOptionalText( { question: setting.question, placeholder: setting.placeholder, @@ -375,6 +416,15 @@ const askSettingValue = async ( }, prompts, ) + if (value && setting.validate) { + const error = setting.validate(value) + if (error) { + prompts.error(error) + return askSettingValue(params, prompts) + } + } + return value + } case "choice": return prompts.select( setting.question, diff --git a/cli/src/vault.ts b/cli/src/vault.ts index a71fcb633..61e256ecc 100644 --- a/cli/src/vault.ts +++ b/cli/src/vault.ts @@ -29,6 +29,11 @@ export const validateVaultPath = (input: string): VaultPathValidation => { const trimmed = input.trim() if (trimmed === "") return { kind: "error", message: "Vault path is required." } + if (/[*?[]/.test(trimmed)) + return { + kind: "error", + message: "Vault path must not contain glob characters (*, ?, [).", + } const absolutePath = resolve(expandTilde(trimmed)) if (!existsSync(absolutePath)) { From 432e30970221ba781b64407953122b1eca05b41c Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:40:01 -0400 Subject: [PATCH 2/7] style: trim comment padding in validatePublicUrl Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/init.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cli/src/init.ts b/cli/src/init.ts index ee0592590..85be9b00b 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -171,10 +171,8 @@ export const validatePublicUrl = (input: string): PublicUrlValidation => { "Leave /mcp off PUBLIC_URL — it's the base URL and the server adds /mcp itself (e.g. https://vault.example.com).", } } - // Store the input as typed, trimming only a trailing slash so the connect - // URL is `${base}/mcp`, never `${base}//mcp`. URL's own normalization is - // unusable here: `.href` adds a trailing slash and `.origin` drops the path, - // so neither round-trips a reverse-proxy subpath like https://host/api. + // Trim trailing slashes so the connect URL is `${base}/mcp`, never + // `${base}//mcp` — URL.href/.origin don't round-trip reverse-proxy subpaths. return { kind: "ok", url: trimmed.replace(/\/+$/, "") } } From 5b094e3a8d92d4db52cdd428da2496f374fe381c Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:55:56 -0400 Subject: [PATCH 3/7] test(cli): add mixed digit/bracket format validation cases Strengthen DAILY_NOTES_FORMAT digit-outside-brackets test coverage with two cases the parity logic must handle: digits in a format segment alongside bracket-escaped digits ("2024 [Day 2]"), and trailing digits ("YYYY-2024"). Both verify the even/odd index split correctly identifies format spans vs literal content. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/optional-settings.test.ts | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 24f2d79b9..18f4c9b58 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -783,6 +783,42 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) }) + it("rejects digits outside brackets even when brackets contain digits too", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FORMAT"], + "2024 [Day 2]", + "YYYY-MM-DD", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([ + "Date format should use Moment tokens (YYYY, MM, DD), not digits — wrap literal text in [...] brackets.", + ]) + expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) + }) + + it("rejects trailing digits in a format string", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FORMAT"], + "YYYY-2024", + "YYYY-MM-DD", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([ + "Date format should use Moment tokens (YYYY, MM, DD), not digits — wrap literal text in [...] brackets.", + ]) + expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) + }) + it("accepts digits inside bracket escapes", async () => { const scripted = createScriptedPrompts([ ["DAILY_NOTES_FORMAT"], From bd0f52f9e1a05bd7caa83138dc4b48ff3ad023e1 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:07:36 -0400 Subject: [PATCH 4/7] fix(cli): reject query strings and hash fragments in PUBLIC_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A query string or fragment in PUBLIC_URL breaks the CLI's connect URL construction — ${base}/mcp appends /mcp after the query rather than as a path segment. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/init.test.ts | 16 ++++++++++++++++ cli/src/init.ts | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index b232d1c90..67c0429fc 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1493,4 +1493,20 @@ describe("validatePublicUrl", () => { "PUBLIC_URL must be a full http:// or https:// URL (e.g. https://vault.example.com).", }) }) + + it("rejects a URL with a query string", () => { + expect(validatePublicUrl("https://vault.example.com/?tab=2")).toEqual({ + kind: "error", + message: + "PUBLIC_URL must be a bare origin or path — no query string (?...) or fragment (#...).", + }) + }) + + it("rejects a URL with a hash fragment", () => { + expect(validatePublicUrl("https://vault.example.com/#section")).toEqual({ + kind: "error", + message: + "PUBLIC_URL must be a bare origin or path — no query string (?...) or fragment (#...).", + }) + }) }) diff --git a/cli/src/init.ts b/cli/src/init.ts index 85be9b00b..c35525080 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -164,6 +164,13 @@ export const validatePublicUrl = (input: string): PublicUrlValidation => { message: "PUBLIC_URL must not contain credentials (user:password@).", } } + if (url.search || url.hash) { + return { + kind: "error", + message: + "PUBLIC_URL must be a bare origin or path — no query string (?...) or fragment (#...).", + } + } if (TRAILING_MCP_PATH.test(url.pathname)) { return { kind: "error", From 4fe3a5a8e9053580ead9c0e758075bf6e107d617 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:46:39 -0400 Subject: [PATCH 5/7] fix(cli): use raw-string check for query/fragment in PUBLIC_URL url.search and url.hash return "" for bare delimiters (the WHATWG spec treats empty-string and null query/fragment identically), so the parsed-property check missed "https://host/?" and "https://host/#". Switch to trimmed.includes("?") / trimmed.includes("#") which catches both populated and bare delimiters. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/init.test.ts | 16 ++++++++++++++++ cli/src/init.ts | 5 ++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 67c0429fc..1976c7c76 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1509,4 +1509,20 @@ describe("validatePublicUrl", () => { "PUBLIC_URL must be a bare origin or path — no query string (?...) or fragment (#...).", }) }) + + it("rejects a bare trailing query delimiter", () => { + expect(validatePublicUrl("https://vault.example.com/?")).toEqual({ + kind: "error", + message: + "PUBLIC_URL must be a bare origin or path — no query string (?...) or fragment (#...).", + }) + }) + + it("rejects a bare trailing hash delimiter", () => { + expect(validatePublicUrl("https://vault.example.com/#")).toEqual({ + kind: "error", + message: + "PUBLIC_URL must be a bare origin or path — no query string (?...) or fragment (#...).", + }) + }) }) diff --git a/cli/src/init.ts b/cli/src/init.ts index c35525080..64267d99c 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -164,7 +164,10 @@ export const validatePublicUrl = (input: string): PublicUrlValidation => { message: "PUBLIC_URL must not contain credentials (user:password@).", } } - if (url.search || url.hash) { + // Raw-string check: url.search/url.hash return "" for bare delimiters + // (WHATWG spec treats empty-string and null query/fragment identically), + // so a parsed-property check misses "https://host/?" and "https://host/#". + if (trimmed.includes("?") || trimmed.includes("#")) { return { kind: "error", message: From f32b270633371c4006957604864cd86dfddf4465 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:16:51 -0400 Subject: [PATCH 6/7] test(cli): add PTY integration tests for input validation re-prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new interactive scenarios driving the real CLI in a PTY: - glob characters in vault path → error + re-prompt → valid path - credentials in PUBLIC_URL → error + re-prompt → valid URL - query string in PUBLIC_URL → error + re-prompt → valid URL Live-validated against vault-cortex@beta (0.13.1-beta.64, gitHead 4fe3a5a8) before adding the tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/integration/cli-pty.test.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/cli/src/__tests__/integration/cli-pty.test.ts b/cli/src/__tests__/integration/cli-pty.test.ts index 5b3a9134c..a8f509827 100644 --- a/cli/src/__tests__/integration/cli-pty.test.ts +++ b/cli/src/__tests__/integration/cli-pty.test.ts @@ -407,3 +407,154 @@ describe("non-interactive commands", () => { expect(result.transcript).toContain("docker run failed") }) }) + +describe("input validation re-prompts", () => { + it("rejects glob characters in vault path and re-prompts", async () => { + const { vaultDir, configDir } = createPtyWorkDir() + + const prompts: PtyPrompt[] = [ + { match: "How do you want to run", send: "\r", label: "mode → local" }, + { + match: "Path to your Obsidian vault", + send: "/path/to/*vault\r", + label: "vault path → glob (rejected)", + }, + { + match: "glob characters", + send: `${vaultDir}\r`, + label: "re-prompt → valid path", + }, + { + match: "Where should I put the config", + send: `${configDir}\r`, + label: "config dir", + }, + { + match: "Any optional settings", + send: "\r", + label: "optional settings → skip", + }, + { match: "Start the server now", send: "n\r", label: "start → no" }, + ] + + const result = await drivePty({ + args: ["init"], + workDir: vaultDir, + prompts, + }) + + expect(result.exitCode).toBe(0) + expect(result.promptsAnswered).toBe(result.totalPrompts) + expect(result.transcript).toContain( + "Vault path must not contain glob characters", + ) + }) + + it("rejects credentials in PUBLIC_URL and re-prompts", async () => { + const { vaultDir, configDir } = createPtyWorkDir() + + const prompts: PtyPrompt[] = [ + { + match: "How do you want to run", + send: `${DOWN}\r`, + label: "mode → remote", + }, + { + match: "Where should I put the config", + send: `${configDir}\r`, + label: "config dir", + }, + { + match: "Public base URL", + send: "https://user:pass@vault.example.com\r", + label: "PUBLIC_URL → credentials (rejected)", + }, + { + match: "credentials", + send: "https://vault.example.com\r", + label: "re-prompt → valid URL", + }, + { + match: "Exact name of your Obsidian vault", + send: "TestVault\r", + label: "vault name", + }, + { + match: "Generate the token now", + send: "n\r", + label: "auto-capture → no", + }, + { match: "end-to-end encryption", send: "n\r", label: "E2E → no" }, + { + match: "Any optional settings", + send: "\r", + label: "optional settings → skip", + }, + ] + + const result = await drivePty({ + args: ["init"], + workDir: vaultDir, + prompts, + }) + + expect(result.exitCode).toBe(0) + expect(result.promptsAnswered).toBe(result.totalPrompts) + expect(result.transcript).toContain( + "PUBLIC_URL must not contain credentials", + ) + }) + + it("rejects a query string in PUBLIC_URL and re-prompts", async () => { + const { vaultDir, configDir } = createPtyWorkDir() + + const prompts: PtyPrompt[] = [ + { + match: "How do you want to run", + send: `${DOWN}\r`, + label: "mode → remote", + }, + { + match: "Where should I put the config", + send: `${configDir}\r`, + label: "config dir", + }, + { + match: "Public base URL", + send: "https://vault.example.com/?tab=2\r", + label: "PUBLIC_URL → query string (rejected)", + }, + { + match: "query string", + send: "https://vault.example.com\r", + label: "re-prompt → valid URL", + }, + { + match: "Exact name of your Obsidian vault", + send: "TestVault\r", + label: "vault name", + }, + { + match: "Generate the token now", + send: "n\r", + label: "auto-capture → no", + }, + { match: "end-to-end encryption", send: "n\r", label: "E2E → no" }, + { + match: "Any optional settings", + send: "\r", + label: "optional settings → skip", + }, + ] + + const result = await drivePty({ + args: ["init"], + workDir: vaultDir, + prompts, + }) + + expect(result.exitCode).toBe(0) + expect(result.promptsAnswered).toBe(result.totalPrompts) + expect(result.transcript).toContain("no query string") + }) +}) From 9f2c65b8b511630c946a74a220845513aa516829 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:45:26 -0400 Subject: [PATCH 7/7] test(cli): add PTY tests for MEMORY_DIR traversal and format digit rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more interactive validation scenarios: - MEMORY_DIR: enters "../secret" → traversal error → re-prompts → valid - DAILY_NOTES_FORMAT: enters "2024-MM-DD" → digit error → re-prompts → valid Live-validated both against vault-cortex@beta before adding. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/integration/cli-pty.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/cli/src/__tests__/integration/cli-pty.test.ts b/cli/src/__tests__/integration/cli-pty.test.ts index a8f509827..9c3266709 100644 --- a/cli/src/__tests__/integration/cli-pty.test.ts +++ b/cli/src/__tests__/integration/cli-pty.test.ts @@ -557,4 +557,98 @@ describe("input validation re-prompts", () => { expect(result.promptsAnswered).toBe(result.totalPrompts) expect(result.transcript).toContain("no query string") }) + + it("rejects traversal in MEMORY_DIR and re-prompts", async () => { + const { vaultDir, configDir } = createPtyWorkDir() + + // MEMORY_DIR is index 1 in the settings list: 1× down, space, enter + const selectMemoryDir = DOWN + " \r" + + const prompts: PtyPrompt[] = [ + { match: "How do you want to run", send: "\r", label: "mode → local" }, + { + match: "Path to your Obsidian vault", + send: `${vaultDir}\r`, + label: "vault path", + }, + { + match: "Where should I put the config", + send: `${configDir}\r`, + label: "config dir", + }, + { + match: "Any optional settings", + send: selectMemoryDir, + label: "select MEMORY_DIR", + }, + { + match: "Vault folder for the memory files", + send: "../secret\r", + label: "memory dir → traversal (rejected)", + }, + { + match: "Path traversal", + send: "My Notes\r", + label: "re-prompt → valid folder", + }, + { match: "Start the server now", send: "n\r", label: "start → no" }, + ] + + const result = await drivePty({ + args: ["init"], + workDir: vaultDir, + prompts, + }) + + expect(result.exitCode).toBe(0) + expect(result.promptsAnswered).toBe(result.totalPrompts) + expect(result.transcript).toContain("Path traversal (..) is not allowed") + }) + + it("rejects digits outside brackets in DAILY_NOTES_FORMAT and re-prompts", async () => { + const { vaultDir, configDir } = createPtyWorkDir() + + // DAILY_NOTES_FORMAT is index 3 in the settings list: 3× down, space, enter + const selectFormat = DOWN.repeat(3) + " \r" + + const prompts: PtyPrompt[] = [ + { match: "How do you want to run", send: "\r", label: "mode → local" }, + { + match: "Path to your Obsidian vault", + send: `${vaultDir}\r`, + label: "vault path", + }, + { + match: "Where should I put the config", + send: `${configDir}\r`, + label: "config dir", + }, + { + match: "Any optional settings", + send: selectFormat, + label: "select DAILY_NOTES_FORMAT", + }, + { + match: "Filename date format", + send: "2024-MM-DD\r", + label: "format → digits (rejected)", + }, + { + match: "Moment tokens", + send: "YYYY-MM-DD\r", + label: "re-prompt → valid format", + }, + { match: "Start the server now", send: "n\r", label: "start → no" }, + ] + + const result = await drivePty({ + args: ["init"], + workDir: vaultDir, + prompts, + }) + + expect(result.exitCode).toBe(0) + expect(result.promptsAnswered).toBe(result.totalPrompts) + expect(result.transcript).toContain("Date format should use Moment tokens") + }) })