diff --git a/src/hooks/athena-sisyphus-only/index.test.ts b/src/hooks/athena-sisyphus-only/index.test.ts index 056089497..2710d2f11 100644 --- a/src/hooks/athena-sisyphus-only/index.test.ts +++ b/src/hooks/athena-sisyphus-only/index.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from "bun:test" +/// + +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) + }) }) }) diff --git a/src/hooks/athena-sisyphus-only/path-policy.ts b/src/hooks/athena-sisyphus-only/path-policy.ts index c6f8da102..598fae612 100644 --- a/src/hooks/athena-sisyphus-only/path-policy.ts +++ b/src/hooks/athena-sisyphus-only/path-policy.ts @@ -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 }