mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-12 11:48:42 +00:00
refactor: apply minimal tfcode branding
- 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.
This commit is contained in:
198
packages/tfcode/test/file/path-traversal.test.ts
Normal file
198
packages/tfcode/test/file/path-traversal.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user