mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-30 13:54:01 +00:00
- Rename packages/opencode → packages/tfcode (directory only) - Rename bin/opencode → bin/tfcode (CLI binary) - Rename .opencode → .tfcode (config directory) - Update package.json name and bin field - Update config directory path references (.tfcode) - Keep internal code references as 'opencode' for easy upstream sync - Keep @opencode-ai/* workspace package names This minimal branding approach allows clean merges from upstream opencode repository while providing tfcode branding for users.
199 lines
6.6 KiB
TypeScript
199 lines
6.6 KiB
TypeScript
import { test, expect, describe } from "bun:test"
|
|
import path from "path"
|
|
import fs from "fs/promises"
|
|
import { Filesystem } from "../../src/util/filesystem"
|
|
import { File } from "../../src/file"
|
|
import { Instance } from "../../src/project/instance"
|
|
import { tmpdir } from "../fixture/fixture"
|
|
|
|
describe("Filesystem.contains", () => {
|
|
test("allows paths within project", () => {
|
|
expect(Filesystem.contains("/project", "/project/src")).toBe(true)
|
|
expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true)
|
|
expect(Filesystem.contains("/project", "/project")).toBe(true)
|
|
})
|
|
|
|
test("blocks ../ traversal", () => {
|
|
expect(Filesystem.contains("/project", "/project/../etc")).toBe(false)
|
|
expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false)
|
|
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
|
|
})
|
|
|
|
test("blocks absolute paths outside project", () => {
|
|
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
|
|
expect(Filesystem.contains("/project", "/tmp/file")).toBe(false)
|
|
expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false)
|
|
})
|
|
|
|
test("handles prefix collision edge cases", () => {
|
|
expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
|
|
expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
|
|
})
|
|
})
|
|
|
|
/*
|
|
* Integration tests for File.read() and File.list() path traversal protection.
|
|
*
|
|
* These tests verify the HTTP API code path is protected. The HTTP endpoints
|
|
* in server.ts (GET /file/content, GET /file) call File.read()/File.list()
|
|
* directly - they do NOT go through ReadTool or the agent permission layer.
|
|
*
|
|
* This is a SEPARATE code path from ReadTool, which has its own checks.
|
|
*/
|
|
describe("File.read path traversal protection", () => {
|
|
test("rejects ../ traversal attempting to read /etc/passwd", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(path.join(dir, "allowed.txt"), "allowed content")
|
|
},
|
|
})
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("rejects deeply nested traversal", async () => {
|
|
await using tmp = await tmpdir()
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
|
|
"Access denied: path escapes project directory",
|
|
)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("allows valid paths within project", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(path.join(dir, "valid.txt"), "valid content")
|
|
},
|
|
})
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const result = await File.read("valid.txt")
|
|
expect(result.content).toBe("valid content")
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("File.list path traversal protection", () => {
|
|
test("rejects ../ traversal attempting to list /etc", async () => {
|
|
await using tmp = await tmpdir()
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("allows valid subdirectory listing", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(path.join(dir, "subdir", "file.txt"), "content")
|
|
},
|
|
})
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const result = await File.list("subdir")
|
|
expect(Array.isArray(result)).toBe(true)
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("Instance.containsPath", () => {
|
|
test("returns true for path inside directory", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: () => {
|
|
expect(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true)
|
|
expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("returns true for path inside worktree but outside directory (monorepo subdirectory scenario)", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
const subdir = path.join(tmp.path, "packages", "lib")
|
|
await fs.mkdir(subdir, { recursive: true })
|
|
|
|
await Instance.provide({
|
|
directory: subdir,
|
|
fn: () => {
|
|
// .opencode at worktree root, but we're running from packages/lib
|
|
expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true)
|
|
// sibling package should also be accessible
|
|
expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true)
|
|
// worktree root itself
|
|
expect(Instance.containsPath(tmp.path)).toBe(true)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("returns false for path outside both directory and worktree", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: () => {
|
|
expect(Instance.containsPath("/etc/passwd")).toBe(false)
|
|
expect(Instance.containsPath("/tmp/other-project")).toBe(false)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("returns false for path with .. escaping worktree", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: () => {
|
|
expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("handles directory === worktree (running from repo root)", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: () => {
|
|
expect(Instance.directory).toBe(Instance.worktree)
|
|
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
|
|
expect(Instance.containsPath("/etc/passwd")).toBe(false)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("non-git project does not allow arbitrary paths via worktree='/'", async () => {
|
|
await using tmp = await tmpdir() // no git: true
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: () => {
|
|
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
|
|
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
|
|
expect(Instance.containsPath("/etc/passwd")).toBe(false)
|
|
expect(Instance.containsPath("/tmp/other")).toBe(false)
|
|
},
|
|
})
|
|
})
|
|
})
|