Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
603 changes: 603 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts

Large diffs are not rendered by default.

67 changes: 62 additions & 5 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ import { openMention } from "../mentions"
import { resolveImageMentions } from "../mentions/resolveImageMentions"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { getWorkspacePath } from "../../utils/path"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
import { isPathOutsideWorkspace, decodeUntrustedPathToStable, isRealPathOutsideWorkspace } from "../../utils/pathUtils"
import { Mode, defaultModeSlug } from "../../shared/modes"
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
import { GetModelsOptions } from "../../shared/api"
Expand Down Expand Up @@ -1515,13 +1515,70 @@ export const webviewMessageHandler = async (
}
}
break
case "openFile":
let filePath: string = message.text!
case "openFile": {
const rawPath = message.text || ""
if (!rawPath) {
break
}
// Task markdown links are untrusted, so markdown-sourced openFile
// requests (flagged by the webview with fromMarkdown) must resolve
// inside the current workspace. First-party callers (modes, MCP,
// slash-command settings) may legitimately open global config files
// outside the workspace, so they keep the previous behavior.
const fromMarkdown = message.values?.fromMarkdown === true
const rejectOutsideWorkspace = () => {
void vscode.window.showErrorMessage(
t("common:errors.cannot_access_path", {
path: rawPath,
error: t("common:errors.path_outside_workspace"),
}),
)
}
let filePath = rawPath
// Markdown link targets are URL syntax: percent-decode to a fixed point
// here, at the containment boundary. openFile decodes AFTER this check,
// so a request like `%2e%2e/%2e%2e/.env` would otherwise pass
// containment as a literal and escape only after that later decode.
if (fromMarkdown) {
const decoded = decodeUntrustedPathToStable(rawPath)
// Stryker disable next-line ConditionalExpression,BlockStatement: hostile non-stabilizing encodings are unreachable from the webview (its posts are plain link targets); the bound is defensive
if (decoded === null) {
// Stryker disable next-line CallExpression: hostile non-stabilizing encodings are pinned by the direct decodeUntrustedPathToStable bound test; the webview never posts such values
rejectOutsideWorkspace()
break
}
filePath = decoded
}
if (!path.isAbsolute(filePath)) {
filePath = path.join(getCurrentCwd(), filePath)
const cwd = getCurrentCwd()
if (!cwd) {
void vscode.window.showErrorMessage(
t("common:errors.could_not_open_file", { errorMessage: t("common:errors.no_workspace") }),
)
break
}
filePath = path.resolve(cwd, filePath)
}
// Workspace-boundary validation (defense in depth): the webview already
// rejects traversal in markdown anchors, but refuse any markdown path
// that still resolves outside the workspace.
if (fromMarkdown && isPathOutsideWorkspace(filePath)) {
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
rejectOutsideWorkspace()
break
}
await openFile(filePath, message.values as { create?: boolean; content?: string; line?: number })
// Lexical containment cannot see symlinks: a link inside a workspace
// folder may resolve to a target outside the workspace. Re-check the
// real filesystem path (failing closed) before opening.
if (fromMarkdown && (await isRealPathOutsideWorkspace(filePath))) {
rejectOutsideWorkspace()
break
}
await openFile(
filePath,
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
message.values as { create?: boolean; content?: string; line?: number; fromMarkdown?: boolean },
)
break
}
case "readFileContent": {
const relPath = message.text || ""
if (!relPath) {
Expand Down
60 changes: 60 additions & 0 deletions src/i18n/__tests__/locale-bundles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// npx vitest i18n/__tests__/locale-bundles.spec.ts
//
// The i18n setup loads locale bundles from disk outside the test environment,
// so no other suite imports the bundle files. This spec imports every bundle
// directly: it pins the completeness of the keys this unit adds, and it puts
// the bundle files into the coverage report so changed-line coverage can
// measure them.

import { describe, it, expect } from "vitest"
import ca from "../locales/ca/common.json"
import de from "../locales/de/common.json"
import en from "../locales/en/common.json"
import es from "../locales/es/common.json"
import fr from "../locales/fr/common.json"
import hi from "../locales/hi/common.json"
import id from "../locales/id/common.json"
import itBundle from "../locales/it/common.json"
import ja from "../locales/ja/common.json"
import ko from "../locales/ko/common.json"
import nl from "../locales/nl/common.json"
import pl from "../locales/pl/common.json"
import ptBr from "../locales/pt-BR/common.json"
import ru from "../locales/ru/common.json"
import tr from "../locales/tr/common.json"
import vi from "../locales/vi/common.json"
import zhCn from "../locales/zh-CN/common.json"
import zhTw from "../locales/zh-TW/common.json"

const bundles: Record<string, { errors: Record<string, unknown> }> = {
ca,
de,
en,
es,
fr,
hi,
id,
it: itBundle,
ja,
ko,
nl,
pl,
"pt-BR": ptBr,
ru,
tr,
vi,
"zh-CN": zhCn,
"zh-TW": zhTw,
}

// Keys this unit adds to the common namespace (the openFile workspace
// containment error posted by webviewMessageHandler).
const addedKeys = ["path_outside_workspace"]

describe("common locale bundles", () => {
it.each(Object.keys(bundles))("%s defines every key this unit adds", (locale) => {
for (const key of addedKeys) {
expect(bundles[locale].errors[key]).toEqual(expect.any(String))
}
})
})
1 change: 1 addition & 0 deletions src/i18n/locales/ca/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/de/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"failed_remove_directory": "Failed to remove task directory: {{error}}",
"custom_storage_path_unusable": "Custom storage path \"{{path}}\" is unusable, will use default path",
"cannot_access_path": "Cannot access path {{path}}: {{error}}",
"path_outside_workspace": "Path is outside the workspace",
"settings_import_failed": "Settings import failed: {{error}}.",
"mistake_limit_guidance": "This may indicate a failure in the model's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. \"Try breaking down the task into smaller steps\").",
"violated_organization_allowlist": "Failed to run task: the current profile isn't compatible with your organization settings",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/fr/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/hi/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/id/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/it/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/ja/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/ko/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/nl/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/pl/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/pt-BR/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/ru/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/tr/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading