Skip to content

Commit e1e0891

Browse files
authored
Merge pull request #230 from mcheviron/issues21/add-protectedFilePatterns-config-option
feat: protect file operations from pruning
2 parents c3a7fdc + 507c3af commit e1e0891

File tree

8 files changed

+162
-1
lines changed

8 files changed

+162
-1
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ DCP uses its own config file:
7575
"enabled": false,
7676
"turns": 4,
7777
},
78+
// Protect file operations from pruning via glob patterns
79+
// Patterns match tool parameters.filePath (e.g. read/write/edit)
80+
"protectedFilePatterns": [],
7881
// LLM-driven context pruning tools
7982
"tools": {
8083
// Shared settings for all prune tools

lib/config.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface PluginConfig {
5050
debug: boolean
5151
pruneNotification: "off" | "minimal" | "detailed"
5252
turnProtection: TurnProtection
53+
protectedFilePatterns: string[]
5354
tools: Tools
5455
strategies: {
5556
deduplication: Deduplication
@@ -79,6 +80,7 @@ export const VALID_CONFIG_KEYS = new Set([
7980
"turnProtection",
8081
"turnProtection.enabled",
8182
"turnProtection.turns",
83+
"protectedFilePatterns",
8284
"tools",
8385
"tools.settings",
8486
"tools.settings.nudgeEnabled",
@@ -151,6 +153,22 @@ function validateConfigTypes(config: Record<string, any>): ValidationError[] {
151153
}
152154
}
153155

156+
if (config.protectedFilePatterns !== undefined) {
157+
if (!Array.isArray(config.protectedFilePatterns)) {
158+
errors.push({
159+
key: "protectedFilePatterns",
160+
expected: "string[]",
161+
actual: typeof config.protectedFilePatterns,
162+
})
163+
} else if (!config.protectedFilePatterns.every((v) => typeof v === "string")) {
164+
errors.push({
165+
key: "protectedFilePatterns",
166+
expected: "string[]",
167+
actual: "non-string entries",
168+
})
169+
}
170+
}
171+
154172
// Top-level turnProtection validator
155173
if (config.turnProtection) {
156174
if (
@@ -371,6 +389,7 @@ const defaultConfig: PluginConfig = {
371389
enabled: false,
372390
turns: 4,
373391
},
392+
protectedFilePatterns: [],
374393
tools: {
375394
settings: {
376395
nudgeEnabled: true,
@@ -480,6 +499,9 @@ function createDefaultConfig(): void {
480499
"enabled": false,
481500
"turns": 4
482501
},
502+
// Protect file operations from pruning via glob patterns
503+
// Patterns match tool parameters.filePath (e.g. read/write/edit)
504+
"protectedFilePatterns": [],
483505
// LLM-driven context pruning tools
484506
"tools": {
485507
// Shared settings for all prune tools
@@ -615,6 +637,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
615637
return {
616638
...config,
617639
turnProtection: { ...config.turnProtection },
640+
protectedFilePatterns: [...config.protectedFilePatterns],
618641
tools: {
619642
settings: {
620643
...config.tools.settings,
@@ -670,6 +693,12 @@ export function getConfig(ctx: PluginInput): PluginConfig {
670693
enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled,
671694
turns: result.data.turnProtection?.turns ?? config.turnProtection.turns,
672695
},
696+
protectedFilePatterns: [
697+
...new Set([
698+
...config.protectedFilePatterns,
699+
...(result.data.protectedFilePatterns ?? []),
700+
]),
701+
],
673702
tools: mergeTools(config.tools, result.data.tools as any),
674703
strategies: mergeStrategies(config.strategies, result.data.strategies as any),
675704
}
@@ -706,6 +735,12 @@ export function getConfig(ctx: PluginInput): PluginConfig {
706735
enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled,
707736
turns: result.data.turnProtection?.turns ?? config.turnProtection.turns,
708737
},
738+
protectedFilePatterns: [
739+
...new Set([
740+
...config.protectedFilePatterns,
741+
...(result.data.protectedFilePatterns ?? []),
742+
]),
743+
],
709744
tools: mergeTools(config.tools, result.data.tools as any),
710745
strategies: mergeStrategies(config.strategies, result.data.strategies as any),
711746
}
@@ -739,6 +774,12 @@ export function getConfig(ctx: PluginInput): PluginConfig {
739774
enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled,
740775
turns: result.data.turnProtection?.turns ?? config.turnProtection.turns,
741776
},
777+
protectedFilePatterns: [
778+
...new Set([
779+
...config.protectedFilePatterns,
780+
...(result.data.protectedFilePatterns ?? []),
781+
]),
782+
],
742783
tools: mergeTools(config.tools, result.data.tools as any),
743784
strategies: mergeStrategies(config.strategies, result.data.strategies as any),
744785
}

lib/messages/inject.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { PluginConfig } from "../config"
44
import type { UserMessage } from "@opencode-ai/sdk/v2"
55
import { loadPrompt } from "../prompts"
66
import { extractParameterKey, buildToolIdList, createSyntheticUserMessage } from "./utils"
7+
import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns"
78
import { getLastUserMessage } from "../shared-utils"
89

910
const getNudgeString = (config: PluginConfig): string => {
@@ -62,6 +63,11 @@ const buildPrunableToolsList = (
6263
return
6364
}
6465

66+
const filePath = getFilePathFromParameters(toolParameterEntry.parameters)
67+
if (isProtectedFilePath(filePath, config.protectedFilePatterns)) {
68+
return
69+
}
70+
6571
const numericId = toolIdList.indexOf(toolCallId)
6672
if (numericId === -1) {
6773
logger.warn(`Tool in cache but not in toolIdList - possible stale entry`, {

lib/protected-file-patterns.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
function normalizePath(input: string): string {
2+
return input.replaceAll("\\\\", "/")
3+
}
4+
5+
function escapeRegExpChar(ch: string): string {
6+
return /[\\.^$+{}()|\[\]]/.test(ch) ? `\\${ch}` : ch
7+
}
8+
9+
/**
10+
* Basic glob matching with support for `**`, `*`, and `?`.
11+
*
12+
* Notes:
13+
* - Matching is performed against the full (normalized) string.
14+
* - `*` and `?` do not match `/`.
15+
* - `**` matches across `/`.
16+
*/
17+
export function matchesGlob(inputPath: string, pattern: string): boolean {
18+
if (!pattern) return false
19+
20+
const input = normalizePath(inputPath)
21+
const pat = normalizePath(pattern)
22+
23+
let regex = "^"
24+
25+
for (let i = 0; i < pat.length; i++) {
26+
const ch = pat[i]
27+
28+
if (ch === "*") {
29+
const next = pat[i + 1]
30+
if (next === "*") {
31+
const after = pat[i + 2]
32+
if (after === "/") {
33+
// **/ (zero or more directories)
34+
regex += "(?:.*/)?"
35+
i += 2
36+
continue
37+
}
38+
39+
// **
40+
regex += ".*"
41+
i++
42+
continue
43+
}
44+
45+
// *
46+
regex += "[^/]*"
47+
continue
48+
}
49+
50+
if (ch === "?") {
51+
regex += "[^/]"
52+
continue
53+
}
54+
55+
if (ch === "/") {
56+
regex += "/"
57+
continue
58+
}
59+
60+
regex += escapeRegExpChar(ch)
61+
}
62+
63+
regex += "$"
64+
65+
return new RegExp(regex).test(input)
66+
}
67+
68+
export function getFilePathFromParameters(parameters: unknown): string | undefined {
69+
if (typeof parameters !== "object" || parameters === null) {
70+
return undefined
71+
}
72+
73+
const filePath = (parameters as Record<string, unknown>).filePath
74+
return typeof filePath === "string" && filePath.length > 0 ? filePath : undefined
75+
}
76+
77+
export function isProtectedFilePath(filePath: string | undefined, patterns: string[]): boolean {
78+
if (!filePath) return false
79+
if (!patterns || patterns.length === 0) return false
80+
81+
return patterns.some((pattern) => matchesGlob(filePath, pattern))
82+
}

lib/strategies/deduplication.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PluginConfig } from "../config"
22
import { Logger } from "../logger"
33
import type { SessionState, WithParts } from "../state"
44
import { buildToolIdList } from "../messages/utils"
5+
import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns"
56
import { calculateTokensSaved } from "./utils"
67

78
/**
@@ -50,6 +51,11 @@ export const deduplicate = (
5051
continue
5152
}
5253

54+
const filePath = getFilePathFromParameters(metadata.parameters)
55+
if (isProtectedFilePath(filePath, config.protectedFilePatterns)) {
56+
continue
57+
}
58+
5359
const signature = createToolSignature(metadata.tool, metadata.parameters)
5460
if (!signatureMap.has(signature)) {
5561
signatureMap.set(signature, [])

lib/strategies/purge-errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PluginConfig } from "../config"
22
import { Logger } from "../logger"
33
import type { SessionState, WithParts } from "../state"
44
import { buildToolIdList } from "../messages/utils"
5+
import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns"
56
import { calculateTokensSaved } from "./utils"
67

78
/**
@@ -52,6 +53,11 @@ export const purgeErrors = (
5253
continue
5354
}
5455

56+
const filePath = getFilePathFromParameters(metadata.parameters)
57+
if (isProtectedFilePath(filePath, config.protectedFilePatterns)) {
58+
continue
59+
}
60+
5561
// Only process error tools
5662
if (metadata.status !== "error") {
5763
continue

lib/strategies/supersede-writes.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PluginConfig } from "../config"
22
import { Logger } from "../logger"
33
import type { SessionState, WithParts } from "../state"
44
import { buildToolIdList } from "../messages/utils"
5+
import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns"
56
import { calculateTokensSaved } from "./utils"
67

78
/**
@@ -50,11 +51,15 @@ export const supersedeWrites = (
5051
continue
5152
}
5253

53-
const filePath = metadata.parameters?.filePath
54+
const filePath = getFilePathFromParameters(metadata.parameters)
5455
if (!filePath) {
5556
continue
5657
}
5758

59+
if (isProtectedFilePath(filePath, config.protectedFilePatterns)) {
60+
continue
61+
}
62+
5863
if (metadata.tool === "write") {
5964
if (!writesByFile.has(filePath)) {
6065
writesByFile.set(filePath, [])

lib/strategies/tools.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { saveSessionState } from "../state/persistence"
99
import type { Logger } from "../logger"
1010
import { loadPrompt } from "../prompts"
1111
import { calculateTokensSaved, getCurrentParams } from "./utils"
12+
import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns"
1213

1314
const DISCARD_TOOL_DESCRIPTION = loadPrompt("discard-tool-spec")
1415
const EXTRACT_TOOL_DESCRIPTION = loadPrompt("extract-tool-spec")
@@ -88,6 +89,17 @@ async function executePruneOperation(
8889
})
8990
return "Invalid IDs provided. Only use numeric IDs from the <prunable-tools> list."
9091
}
92+
93+
const filePath = getFilePathFromParameters(metadata.parameters)
94+
if (isProtectedFilePath(filePath, config.protectedFilePatterns)) {
95+
logger.debug("Rejecting prune request - protected file path", {
96+
index,
97+
id,
98+
tool: metadata.tool,
99+
filePath,
100+
})
101+
return "Invalid IDs provided. Only use numeric IDs from the <prunable-tools> list."
102+
}
91103
}
92104

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

0 commit comments

Comments
 (0)