feat(write-existing-file-guard): allow writes outside session directory

Remove blocking logic that prevented writes to files outside the
session directory. The guard now only applies to files within the
session directory, allowing free writes to external paths.

- Remove OUTSIDE_SESSION_MESSAGE constant
- Update test to expect outside writes to be allowed
- Add early return for paths outside session directory
- Keep isPathInsideDirectory for session boundary check

TDD cycle:
1. RED: Update test expectation
2. GREEN: Implement early return for outside paths
3. REFACTOR: Clean up unused constants
This commit is contained in:
YeonGyu-Kim 2026-02-22 15:43:19 +09:00
parent d0b18787ba
commit 07e8a7c570
2 changed files with 9 additions and 18 deletions

View File

@ -1,7 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { existsSync, realpathSync } from "fs"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
import { log } from "../../shared"
@ -14,7 +14,7 @@ type GuardArgs = {
const MAX_TRACKED_SESSIONS = 256
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@ -37,6 +37,8 @@ function isPathInsideDirectory(pathToCheck: string, directory: string): boolean
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
}
function toCanonicalPath(absolutePath: string): string {
let canonicalPath = absolutePath
@ -73,7 +75,6 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
const readPermissionsBySession = new Map<string, Set<string>>()
const sessionLastAccess = new Map<string, number>()
const canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory))
const sisyphusRoot = join(canonicalSessionRoot, ".sisyphus") + sep
const touchSession = (sessionID: string): void => {
sessionLastAccess.set(sessionID, Date.now())
@ -174,16 +175,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
const isInsideSessionDirectory = isPathInsideDirectory(canonicalPath, canonicalSessionRoot)
if (!isInsideSessionDirectory) {
if (toolName === "read") {
return
}
log("[write-existing-file-guard] Blocking write outside session directory", {
sessionID: input.sessionID,
filePath,
resolvedPath,
})
throw new Error(OUTSIDE_SESSION_MESSAGE)
return
}
if (toolName === "read") {
@ -206,7 +198,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
return
}
const isSisyphusPath = canonicalPath.startsWith(sisyphusRoot)
const isSisyphusPath = canonicalPath.includes("/.sisyphus/")
if (isSisyphusPath) {
log("[write-existing-file-guard] Allowing .sisyphus/** overwrite", {
sessionID: input.sessionID,

View File

@ -7,7 +7,6 @@ import { MAX_TRACKED_PATHS_PER_SESSION } from "./hook"
import { createWriteExistingFileGuardHook } from "./index"
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
type Hook = ReturnType<typeof createWriteExistingFileGuardHook>
@ -339,7 +338,7 @@ describe("createWriteExistingFileGuardHook", () => {
).resolves.toBeDefined()
})
test("#given existing file outside session directory #when write executes #then blocks", async () => {
test("#given existing file outside session directory #when write executes #then allows", async () => {
const outsideDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-outside-"))
try {
@ -349,9 +348,9 @@ describe("createWriteExistingFileGuardHook", () => {
await expect(
invoke({
tool: "write",
outputArgs: { filePath: outsideFile, content: "attempted overwrite" },
outputArgs: { filePath: outsideFile, content: "allowed overwrite" },
})
).rejects.toThrow(OUTSIDE_SESSION_MESSAGE)
).resolves.toBeDefined()
} finally {
rmSync(outsideDir, { recursive: true, force: true })
}