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:
ismeth
2026-03-14 15:32:25 +01:00
committed by YeonGyu-Kim
parent 9d6dc3a8a8
commit 69bb6eda8a
2 changed files with 127 additions and 12 deletions
+73 -2
View File
@@ -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)
})
})
})
+54 -10
View File
@@ -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
}