diff --git a/README.md b/README.md index 3278c06..a4186cb 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ DCP uses its own config file: "enabled": false, "turns": 4, }, + // Protect file operations from pruning via glob patterns + // Patterns match tool parameters.filePath (e.g. read/write/edit) + "protectedFilePatterns": [], // LLM-driven context pruning tools "tools": { // Shared settings for all prune tools diff --git a/lib/config.ts b/lib/config.ts index 2b48263..1104c0c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -50,6 +50,7 @@ export interface PluginConfig { debug: boolean pruneNotification: "off" | "minimal" | "detailed" turnProtection: TurnProtection + protectedFilePatterns: string[] tools: Tools strategies: { deduplication: Deduplication @@ -79,6 +80,7 @@ export const VALID_CONFIG_KEYS = new Set([ "turnProtection", "turnProtection.enabled", "turnProtection.turns", + "protectedFilePatterns", "tools", "tools.settings", "tools.settings.nudgeEnabled", @@ -151,6 +153,22 @@ function validateConfigTypes(config: Record): ValidationError[] { } } + if (config.protectedFilePatterns !== undefined) { + if (!Array.isArray(config.protectedFilePatterns)) { + errors.push({ + key: "protectedFilePatterns", + expected: "string[]", + actual: typeof config.protectedFilePatterns, + }) + } else if (!config.protectedFilePatterns.every((v) => typeof v === "string")) { + errors.push({ + key: "protectedFilePatterns", + expected: "string[]", + actual: "non-string entries", + }) + } + } + // Top-level turnProtection validator if (config.turnProtection) { if ( @@ -371,6 +389,7 @@ const defaultConfig: PluginConfig = { enabled: false, turns: 4, }, + protectedFilePatterns: [], tools: { settings: { nudgeEnabled: true, @@ -480,6 +499,9 @@ function createDefaultConfig(): void { "enabled": false, "turns": 4 }, + // Protect file operations from pruning via glob patterns + // Patterns match tool parameters.filePath (e.g. read/write/edit) + "protectedFilePatterns": [], // LLM-driven context pruning tools "tools": { // Shared settings for all prune tools @@ -615,6 +637,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { return { ...config, turnProtection: { ...config.turnProtection }, + protectedFilePatterns: [...config.protectedFilePatterns], tools: { settings: { ...config.tools.settings, @@ -670,6 +693,12 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, }, + protectedFilePatterns: [ + ...new Set([ + ...config.protectedFilePatterns, + ...(result.data.protectedFilePatterns ?? []), + ]), + ], tools: mergeTools(config.tools, result.data.tools as any), strategies: mergeStrategies(config.strategies, result.data.strategies as any), } @@ -706,6 +735,12 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, }, + protectedFilePatterns: [ + ...new Set([ + ...config.protectedFilePatterns, + ...(result.data.protectedFilePatterns ?? []), + ]), + ], tools: mergeTools(config.tools, result.data.tools as any), strategies: mergeStrategies(config.strategies, result.data.strategies as any), } @@ -739,6 +774,12 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, }, + protectedFilePatterns: [ + ...new Set([ + ...config.protectedFilePatterns, + ...(result.data.protectedFilePatterns ?? []), + ]), + ], tools: mergeTools(config.tools, result.data.tools as any), strategies: mergeStrategies(config.strategies, result.data.strategies as any), } diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 11153b7..5d9b36f 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -4,6 +4,7 @@ import type { PluginConfig } from "../config" import type { UserMessage } from "@opencode-ai/sdk/v2" import { loadPrompt } from "../prompts" import { extractParameterKey, buildToolIdList, createSyntheticUserMessage } from "./utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { getLastUserMessage } from "../shared-utils" const getNudgeString = (config: PluginConfig): string => { @@ -62,6 +63,11 @@ const buildPrunableToolsList = ( return } + const filePath = getFilePathFromParameters(toolParameterEntry.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + return + } + const numericId = toolIdList.indexOf(toolCallId) if (numericId === -1) { logger.warn(`Tool in cache but not in toolIdList - possible stale entry`, { diff --git a/lib/protected-file-patterns.ts b/lib/protected-file-patterns.ts new file mode 100644 index 0000000..3370e20 --- /dev/null +++ b/lib/protected-file-patterns.ts @@ -0,0 +1,82 @@ +function normalizePath(input: string): string { + return input.replaceAll("\\\\", "/") +} + +function escapeRegExpChar(ch: string): string { + return /[\\.^$+{}()|\[\]]/.test(ch) ? `\\${ch}` : ch +} + +/** + * Basic glob matching with support for `**`, `*`, and `?`. + * + * Notes: + * - Matching is performed against the full (normalized) string. + * - `*` and `?` do not match `/`. + * - `**` matches across `/`. + */ +export function matchesGlob(inputPath: string, pattern: string): boolean { + if (!pattern) return false + + const input = normalizePath(inputPath) + const pat = normalizePath(pattern) + + let regex = "^" + + for (let i = 0; i < pat.length; i++) { + const ch = pat[i] + + if (ch === "*") { + const next = pat[i + 1] + if (next === "*") { + const after = pat[i + 2] + if (after === "/") { + // **/ (zero or more directories) + regex += "(?:.*/)?" + i += 2 + continue + } + + // ** + regex += ".*" + i++ + continue + } + + // * + regex += "[^/]*" + continue + } + + if (ch === "?") { + regex += "[^/]" + continue + } + + if (ch === "/") { + regex += "/" + continue + } + + regex += escapeRegExpChar(ch) + } + + regex += "$" + + return new RegExp(regex).test(input) +} + +export function getFilePathFromParameters(parameters: unknown): string | undefined { + if (typeof parameters !== "object" || parameters === null) { + return undefined + } + + const filePath = (parameters as Record).filePath + return typeof filePath === "string" && filePath.length > 0 ? filePath : undefined +} + +export function isProtectedFilePath(filePath: string | undefined, patterns: string[]): boolean { + if (!filePath) return false + if (!patterns || patterns.length === 0) return false + + return patterns.some((pattern) => matchesGlob(filePath, pattern)) +} diff --git a/lib/strategies/deduplication.ts b/lib/strategies/deduplication.ts index 101e664..fb6ce4e 100644 --- a/lib/strategies/deduplication.ts +++ b/lib/strategies/deduplication.ts @@ -2,6 +2,7 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import { buildToolIdList } from "../messages/utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" /** @@ -50,6 +51,11 @@ export const deduplicate = ( continue } + const filePath = getFilePathFromParameters(metadata.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + continue + } + const signature = createToolSignature(metadata.tool, metadata.parameters) if (!signatureMap.has(signature)) { signatureMap.set(signature, []) diff --git a/lib/strategies/purge-errors.ts b/lib/strategies/purge-errors.ts index 84d3aa8..c3debf6 100644 --- a/lib/strategies/purge-errors.ts +++ b/lib/strategies/purge-errors.ts @@ -2,6 +2,7 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import { buildToolIdList } from "../messages/utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" /** @@ -52,6 +53,11 @@ export const purgeErrors = ( continue } + const filePath = getFilePathFromParameters(metadata.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + continue + } + // Only process error tools if (metadata.status !== "error") { continue diff --git a/lib/strategies/supersede-writes.ts b/lib/strategies/supersede-writes.ts index 327cb58..ef765c4 100644 --- a/lib/strategies/supersede-writes.ts +++ b/lib/strategies/supersede-writes.ts @@ -2,6 +2,7 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import { buildToolIdList } from "../messages/utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" /** @@ -50,11 +51,15 @@ export const supersedeWrites = ( continue } - const filePath = metadata.parameters?.filePath + const filePath = getFilePathFromParameters(metadata.parameters) if (!filePath) { continue } + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + continue + } + if (metadata.tool === "write") { if (!writesByFile.has(filePath)) { writesByFile.set(filePath, []) diff --git a/lib/strategies/tools.ts b/lib/strategies/tools.ts index 6ca7d59..e3d8e03 100644 --- a/lib/strategies/tools.ts +++ b/lib/strategies/tools.ts @@ -9,6 +9,7 @@ import { saveSessionState } from "../state/persistence" import type { Logger } from "../logger" import { loadPrompt } from "../prompts" import { calculateTokensSaved, getCurrentParams } from "./utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" const DISCARD_TOOL_DESCRIPTION = loadPrompt("discard-tool-spec") const EXTRACT_TOOL_DESCRIPTION = loadPrompt("extract-tool-spec") @@ -88,6 +89,17 @@ async function executePruneOperation( }) return "Invalid IDs provided. Only use numeric IDs from the list." } + + const filePath = getFilePathFromParameters(metadata.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + logger.debug("Rejecting prune request - protected file path", { + index, + id, + tool: metadata.tool, + filePath, + }) + return "Invalid IDs provided. Only use numeric IDs from the list." + } } const pruneToolIds: string[] = numericToolIds.map((index) => toolIdList[index])