Files
oh-my-opencode/src/hooks/write-existing-file-guard/hook.ts
T
gustavosmendes 73d9e1f847 fix(write-existing-file-guard): wire cleanup through event dispatcher
Forward session.deleted events to write-existing-file-guard so per-session read permissions are actually cleared in runtime.

Add plugin-level regression test to ensure event forwarding remains wired, alongside the expanded guard behavior and unit coverage.
2026-02-18 16:50:30 -03:00

263 lines
7.6 KiB
TypeScript

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 { log } from "../../shared"
type GuardArgs = {
filePath?: string
path?: string
file_path?: string
overwrite?: boolean | string
}
const MAX_TRACKED_SESSIONS = 256
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined
}
return value as Record<string, unknown>
}
function getPathFromArgs(args: GuardArgs | undefined): string | undefined {
return args?.filePath ?? args?.path ?? args?.file_path
}
function resolveInputPath(ctx: PluginInput, inputPath: string): string {
return normalize(isAbsolute(inputPath) ? inputPath : resolve(ctx.directory, inputPath))
}
function isPathInsideDirectory(pathToCheck: string, directory: string): boolean {
const relativePath = relative(directory, pathToCheck)
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
}
function toCanonicalPath(absolutePath: string): string {
let canonicalPath = absolutePath
if (existsSync(absolutePath)) {
try {
canonicalPath = realpathSync.native(absolutePath)
} catch {
canonicalPath = absolutePath
}
} else {
const absoluteDir = dirname(absolutePath)
const resolvedDir = existsSync(absoluteDir) ? realpathSync.native(absoluteDir) : absoluteDir
canonicalPath = join(resolvedDir, basename(absolutePath))
}
// Preserve canonical casing from the filesystem to avoid collapsing distinct
// files on case-sensitive volumes (supported on all major OSes).
return normalize(canonicalPath)
}
function isOverwriteEnabled(value: boolean | string | undefined): boolean {
if (value === true) {
return true
}
if (typeof value === "string") {
return value.toLowerCase() === "true"
}
return false
}
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())
}
const evictLeastRecentlyUsedSession = (): void => {
let oldestSessionID: string | undefined
let oldestSeen = Number.POSITIVE_INFINITY
for (const [sessionID, lastSeen] of sessionLastAccess.entries()) {
if (lastSeen < oldestSeen) {
oldestSeen = lastSeen
oldestSessionID = sessionID
}
}
if (!oldestSessionID) {
return
}
readPermissionsBySession.delete(oldestSessionID)
sessionLastAccess.delete(oldestSessionID)
}
const ensureSessionReadSet = (sessionID: string): Set<string> => {
let readSet = readPermissionsBySession.get(sessionID)
if (!readSet) {
if (readPermissionsBySession.size >= MAX_TRACKED_SESSIONS) {
evictLeastRecentlyUsedSession()
}
readSet = new Set<string>()
readPermissionsBySession.set(sessionID, readSet)
}
touchSession(sessionID)
return readSet
}
const trimSessionReadSet = (readSet: Set<string>): void => {
while (readSet.size > MAX_TRACKED_PATHS_PER_SESSION) {
const oldestPath = readSet.values().next().value
if (!oldestPath) {
return
}
readSet.delete(oldestPath)
}
}
const registerReadPermission = (sessionID: string, canonicalPath: string): void => {
const readSet = ensureSessionReadSet(sessionID)
if (readSet.has(canonicalPath)) {
readSet.delete(canonicalPath)
}
readSet.add(canonicalPath)
trimSessionReadSet(readSet)
}
const consumeReadPermission = (sessionID: string, canonicalPath: string): boolean => {
const readSet = readPermissionsBySession.get(sessionID)
if (!readSet || !readSet.has(canonicalPath)) {
return false
}
readSet.delete(canonicalPath)
touchSession(sessionID)
return true
}
const invalidateOtherSessions = (canonicalPath: string, writingSessionID?: string): void => {
for (const [sessionID, readSet] of readPermissionsBySession.entries()) {
if (writingSessionID && sessionID === writingSessionID) {
continue
}
readSet.delete(canonicalPath)
}
}
return {
"tool.execute.before": async (input, output) => {
const toolName = input.tool?.toLowerCase()
if (toolName !== "write" && toolName !== "read") {
return
}
const argsRecord = asRecord(output.args)
const args = argsRecord as GuardArgs | undefined
const filePath = getPathFromArgs(args)
if (!filePath) {
return
}
const resolvedPath = resolveInputPath(ctx, filePath)
const canonicalPath = toCanonicalPath(resolvedPath)
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)
}
if (toolName === "read") {
if (!existsSync(resolvedPath) || !input.sessionID) {
return
}
registerReadPermission(input.sessionID, canonicalPath)
return
}
const overwriteEnabled = isOverwriteEnabled(args?.overwrite)
if (argsRecord && "overwrite" in argsRecord) {
// Intentionally mutate output args so overwrite bypass remains hook-only.
delete argsRecord.overwrite
}
if (!existsSync(resolvedPath)) {
return
}
const isSisyphusPath = canonicalPath.startsWith(sisyphusRoot)
if (isSisyphusPath) {
log("[write-existing-file-guard] Allowing .sisyphus/** overwrite", {
sessionID: input.sessionID,
filePath,
})
invalidateOtherSessions(canonicalPath, input.sessionID)
return
}
if (overwriteEnabled) {
log("[write-existing-file-guard] Allowing overwrite flag bypass", {
sessionID: input.sessionID,
filePath,
resolvedPath,
})
invalidateOtherSessions(canonicalPath, input.sessionID)
return
}
if (input.sessionID && consumeReadPermission(input.sessionID, canonicalPath)) {
log("[write-existing-file-guard] Allowing overwrite after read", {
sessionID: input.sessionID,
filePath,
resolvedPath,
})
invalidateOtherSessions(canonicalPath, input.sessionID)
return
}
log("[write-existing-file-guard] Blocking write to existing file", {
sessionID: input.sessionID,
filePath,
resolvedPath,
})
throw new Error("File already exists. Use edit tool instead.")
},
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.deleted") {
return
}
const props = event.properties as { info?: { id?: string } } | undefined
const sessionID = props?.info?.id
if (!sessionID) {
return
}
readPermissionsBySession.delete(sessionID)
sessionLastAccess.delete(sessionID)
},
}
}