fix(athena): canonical realpath path validation with symlink rejection
Resolve symlinks via realpathSync before checking path policy, preventing symlink-based escapes from .sisyphus/ sandbox. Walks up to nearest existing ancestor for new-file writes. Adds 7 tests including real-filesystem symlink scenarios. Co-authored-by: Vacbo <2445>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { isAllowedPath } from "./path-policy"
|
||||
import { isAthenaAgent } from "./agent-matcher"
|
||||
|
||||
const WORKSPACE_ROOT = "/fake/workspace"
|
||||
|
||||
describe("athena-sisyphus-only hook", () => {
|
||||
@@ -65,6 +69,73 @@ describe("athena-sisyphus-only hook", () => {
|
||||
const absPath = `${WORKSPACE_ROOT}/.sisyphus/plans/test.md`
|
||||
expect(isAllowedPath(absPath, WORKSPACE_ROOT)).toBe(true)
|
||||
})
|
||||
|
||||
it("#then allows .sisyphus/plans/foo.md when .sisyphus doesn't exist yet (bootstrap)", () => {
|
||||
expect(isAllowedPath(".sisyphus/plans/foo.md", WORKSPACE_ROOT)).toBe(true)
|
||||
})
|
||||
|
||||
it("#then blocks ../etc/passwd even when .sisyphus doesn't exist", () => {
|
||||
expect(isAllowedPath("../etc/passwd", WORKSPACE_ROOT)).toBe(false)
|
||||
})
|
||||
|
||||
it("#then blocks ../../etc/passwd path traversal", () => {
|
||||
expect(isAllowedPath("../../etc/passwd", WORKSPACE_ROOT)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when checking symlink rejection", () => {
|
||||
let tempWorkspaceRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempWorkspaceRoot = await mkdtemp(join(tmpdir(), "athena-sisyphus-only-"))
|
||||
await mkdir(join(tempWorkspaceRoot, ".sisyphus", "tmp"), { recursive: true })
|
||||
await mkdir(join(tempWorkspaceRoot, "outside", "nested"), { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempWorkspaceRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("#then rejects symlink inside .sisyphus/tmp/ pointing outside workspace", async () => {
|
||||
const outsideFile = join(tempWorkspaceRoot, "outside", "secret.md")
|
||||
const symlinkPath = join(tempWorkspaceRoot, ".sisyphus", "tmp", "escape.md")
|
||||
|
||||
await writeFile(outsideFile, "secret", "utf-8")
|
||||
await symlink(outsideFile, symlinkPath)
|
||||
|
||||
expect(isAllowedPath(symlinkPath, tempWorkspaceRoot)).toBe(false)
|
||||
})
|
||||
|
||||
it("#then rejects symlink inside .sisyphus/ pointing to /etc/passwd", async () => {
|
||||
const symlinkPath = join(tempWorkspaceRoot, ".sisyphus", "passwd-link")
|
||||
|
||||
await symlink("/etc/passwd", symlinkPath)
|
||||
|
||||
expect(isAllowedPath(symlinkPath, tempWorkspaceRoot)).toBe(false)
|
||||
})
|
||||
|
||||
it("#then allows a regular file inside .sisyphus/tmp/", async () => {
|
||||
const regularFile = join(tempWorkspaceRoot, ".sisyphus", "tmp", "prompt.md")
|
||||
|
||||
await writeFile(regularFile, "prompt", "utf-8")
|
||||
|
||||
expect(isAllowedPath(regularFile, tempWorkspaceRoot)).toBe(true)
|
||||
})
|
||||
|
||||
it("#then rejects a path with .. traversal", () => {
|
||||
expect(isAllowedPath(".sisyphus/tmp/../../outside/secret.md", tempWorkspaceRoot)).toBe(false)
|
||||
})
|
||||
|
||||
it("#then rejects nested symlinks that escape workspace", async () => {
|
||||
const outsideNestedDir = join(tempWorkspaceRoot, "outside", "nested")
|
||||
const nestedFile = join(outsideNestedDir, "secret.md")
|
||||
const linkedDir = join(tempWorkspaceRoot, ".sisyphus", "tmp", "linked-dir")
|
||||
|
||||
await writeFile(nestedFile, "secret", "utf-8")
|
||||
await symlink(outsideNestedDir, linkedDir)
|
||||
|
||||
expect(isAllowedPath(join(linkedDir, "secret.md"), tempWorkspaceRoot)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { relative, resolve, isAbsolute } from "node:path"
|
||||
import { dirname, isAbsolute, relative, resolve } from "node:path"
|
||||
import { existsSync, realpathSync } from "node:fs"
|
||||
|
||||
/**
|
||||
* Cross-platform path validator for Athena file writes.
|
||||
@@ -9,20 +10,63 @@ import { relative, resolve, isAbsolute } from "node:path"
|
||||
* - Workspace confinement (blocks paths outside root or via traversal)
|
||||
* - No extension restriction: any file type is allowed inside .sisyphus/
|
||||
*/
|
||||
function isWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||
const rel = relative(rootPath, targetPath)
|
||||
return !((rel === ".." || rel.startsWith("../") || rel.startsWith("..\\")) || isAbsolute(rel))
|
||||
}
|
||||
|
||||
function isWithinSisyphusSubtree(targetPath: string, rootPath: string): boolean {
|
||||
const rel = relative(rootPath, targetPath)
|
||||
return /(^|[\/\\])\.sisyphus([\/\\]|$)/i.test(rel)
|
||||
}
|
||||
|
||||
function getNearestExistingPath(targetPath: string): string | null {
|
||||
let current = targetPath
|
||||
while (!existsSync(current)) {
|
||||
const parent = dirname(current)
|
||||
if (parent === current) {
|
||||
return null
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
export function isAllowedPath(filePath: string, workspaceRoot: string): boolean {
|
||||
// 1. Resolve to absolute path
|
||||
const resolved = resolve(workspaceRoot, filePath)
|
||||
const resolvedWorkspaceRoot = resolve(workspaceRoot)
|
||||
const resolved = resolve(resolvedWorkspaceRoot, filePath)
|
||||
|
||||
// 2. Get relative path from workspace root
|
||||
const rel = relative(workspaceRoot, resolved)
|
||||
|
||||
// 3. Reject if escapes root (traversal or absolute path)
|
||||
if ((rel === ".." || rel.startsWith("../") || rel.startsWith("..\\")) || isAbsolute(rel)) {
|
||||
const rel = relative(resolvedWorkspaceRoot, resolved)
|
||||
if (!isWithinRoot(resolved, resolvedWorkspaceRoot)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Check if .sisyphus is a complete path segment
|
||||
if (!/(^|[\/\\])\.sisyphus[\/\\]/i.test(rel)) {
|
||||
if (!/(^|[\/\\])\.sisyphus([\/\\]|$)/i.test(rel)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!existsSync(resolvedWorkspaceRoot)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const existingPath = getNearestExistingPath(resolved)
|
||||
if (!existingPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const realWorkspaceRoot = realpathSync(resolvedWorkspaceRoot)
|
||||
const realExistingPath = realpathSync(existingPath)
|
||||
if (!isWithinRoot(realExistingPath, realWorkspaceRoot)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow bootstrap writes when nearest existing path is workspace root itself
|
||||
// and the target path is within .sisyphus subtree
|
||||
if (realExistingPath === realWorkspaceRoot) {
|
||||
return isWithinSisyphusSubtree(resolved, realWorkspaceRoot)
|
||||
}
|
||||
|
||||
if (!isWithinSisyphusSubtree(realExistingPath, realWorkspaceRoot)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user