Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface PluginConfig {
debug: boolean
pruneNotification: "off" | "minimal" | "detailed"
turnProtection: TurnProtection
protectedFilePatterns: string[]
tools: Tools
strategies: {
deduplication: Deduplication
Expand Down Expand Up @@ -79,6 +80,7 @@ export const VALID_CONFIG_KEYS = new Set([
"turnProtection",
"turnProtection.enabled",
"turnProtection.turns",
"protectedFilePatterns",
"tools",
"tools.settings",
"tools.settings.nudgeEnabled",
Expand Down Expand Up @@ -151,6 +153,22 @@ function validateConfigTypes(config: Record<string, any>): 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 (
Expand Down Expand Up @@ -371,6 +389,7 @@ const defaultConfig: PluginConfig = {
enabled: false,
turns: 4,
},
protectedFilePatterns: [],
tools: {
settings: {
nudgeEnabled: true,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -615,6 +637,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
return {
...config,
turnProtection: { ...config.turnProtection },
protectedFilePatterns: [...config.protectedFilePatterns],
tools: {
settings: {
...config.tools.settings,
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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),
}
Expand Down
6 changes: 6 additions & 0 deletions lib/messages/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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`, {
Expand Down
82 changes: 82 additions & 0 deletions lib/protected-file-patterns.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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))
}
6 changes: 6 additions & 0 deletions lib/strategies/deduplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

/**
Expand Down Expand Up @@ -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, [])
Expand Down
6 changes: 6 additions & 0 deletions lib/strategies/purge-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

/**
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion lib/strategies/supersede-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

/**
Expand Down Expand Up @@ -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, [])
Expand Down
12 changes: 12 additions & 0 deletions lib/strategies/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -88,6 +89,17 @@ async function executePruneOperation(
})
return "Invalid IDs provided. Only use numeric IDs from the <prunable-tools> 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 <prunable-tools> list."
}
}

const pruneToolIds: string[] = numericToolIds.map((index) => toolIdList[index])
Expand Down