Skip to content

Commit e3bdfa3

Browse files
committed
permissions: align compress gating with opencode rules
1 parent 15e8297 commit e3bdfa3

9 files changed

Lines changed: 248 additions & 21 deletions

File tree

index.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { Plugin } from "@opencode-ai/plugin"
22
import { getConfig } from "./lib/config"
3+
import {
4+
compressDisabledByOpencode,
5+
hasExplicitToolPermission,
6+
type HostPermissionSnapshot,
7+
} from "./lib/host-permissions"
38
import { Logger } from "./lib/logger"
49
import { createSessionState } from "./lib/state"
510
import { createCompressTool } from "./lib/tools"
@@ -22,6 +27,10 @@ const plugin: Plugin = (async (ctx) => {
2227
const logger = new Logger(config.debug)
2328
const state = createSessionState()
2429
const prompts = new PromptStore(logger, ctx.directory, config.experimental.customPrompts)
30+
const hostPermissions: HostPermissionSnapshot = {
31+
global: undefined,
32+
agents: {},
33+
}
2534

2635
if (isSecureMode()) {
2736
configureClientAuth(ctx.client)
@@ -46,6 +55,7 @@ const plugin: Plugin = (async (ctx) => {
4655
logger,
4756
config,
4857
prompts,
58+
hostPermissions,
4959
) as any,
5060
"chat.message": async (
5161
input: {
@@ -57,8 +67,6 @@ const plugin: Plugin = (async (ctx) => {
5767
},
5868
_output: any,
5969
) => {
60-
// Cache variant from real user messages (not synthetic)
61-
// This avoids scanning all messages to find variant
6270
state.variant = input.variant
6371
logger.debug("Cached variant from chat.message hook", { variant: input.variant })
6472
},
@@ -69,6 +77,7 @@ const plugin: Plugin = (async (ctx) => {
6977
logger,
7078
config,
7179
ctx.directory,
80+
hostPermissions,
7281
),
7382
tool: {
7483
...(config.compress.permission !== "deny" && {
@@ -91,6 +100,13 @@ const plugin: Plugin = (async (ctx) => {
91100
}
92101
}
93102

103+
if (
104+
config.compress.permission !== "deny" &&
105+
compressDisabledByOpencode(opencodeConfig.permission)
106+
) {
107+
config.compress.permission = "deny"
108+
}
109+
94110
const toolsToAdd: string[] = []
95111
if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) {
96112
toolsToAdd.push("compress")
@@ -104,12 +120,21 @@ const plugin: Plugin = (async (ctx) => {
104120
}
105121
}
106122

107-
// Set tool permissions from DCP config
108-
const permission = opencodeConfig.permission ?? {}
109-
opencodeConfig.permission = {
110-
...permission,
111-
compress: config.compress.permission,
112-
} as typeof permission
123+
if (!hasExplicitToolPermission(opencodeConfig.permission, "compress")) {
124+
const permission = opencodeConfig.permission ?? {}
125+
opencodeConfig.permission = {
126+
...permission,
127+
compress: config.compress.permission,
128+
} as typeof permission
129+
}
130+
131+
hostPermissions.global = opencodeConfig.permission
132+
hostPermissions.agents = Object.fromEntries(
133+
Object.entries(opencodeConfig.agent ?? {}).map(([name, agent]) => [
134+
name,
135+
agent?.permission,
136+
]),
137+
)
113138
},
114139
}
115140
}) satisfies Plugin

lib/commands/help.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import type { Logger } from "../logger"
77
import type { PluginConfig } from "../config"
88
import type { SessionState, WithParts } from "../state"
9+
import { compressPermission } from "../shared-utils"
910
import { sendIgnoredMessage } from "../ui/notification"
1011
import { getCurrentParams } from "../strategies/utils"
1112

@@ -31,10 +32,10 @@ const TOOL_COMMANDS: Record<string, [string, string]> = {
3132
recompress: ["/dcp recompress <n>", "Re-apply a user-decompressed compression"],
3233
}
3334

34-
function getVisibleCommands(config: PluginConfig): [string, string][] {
35+
function getVisibleCommands(state: SessionState, config: PluginConfig): [string, string][] {
3536
const commands = [...BASE_COMMANDS]
3637

37-
if (config.compress.permission !== "deny") {
38+
if (compressPermission(state, config) !== "deny") {
3839
commands.push(TOOL_COMMANDS.compress)
3940
commands.push(TOOL_COMMANDS.decompress)
4041
commands.push(TOOL_COMMANDS.recompress)
@@ -43,16 +44,16 @@ function getVisibleCommands(config: PluginConfig): [string, string][] {
4344
return commands
4445
}
4546

46-
function formatHelpMessage(manualMode: boolean, config: PluginConfig): string {
47-
const commands = getVisibleCommands(config)
47+
function formatHelpMessage(state: SessionState, config: PluginConfig): string {
48+
const commands = getVisibleCommands(state, config)
4849
const colWidth = Math.max(...commands.map(([cmd]) => cmd.length)) + 4
4950
const lines: string[] = []
5051

5152
lines.push("╭─────────────────────────────────────────────────────────────────────────╮")
5253
lines.push("│ DCP Commands │")
5354
lines.push("╰─────────────────────────────────────────────────────────────────────────╯")
5455
lines.push("")
55-
lines.push(` ${"Manual mode:".padEnd(colWidth)}${manualMode ? "ON" : "OFF"}`)
56+
lines.push(` ${"Manual mode:".padEnd(colWidth)}${state.manualMode ? "ON" : "OFF"}`)
5657
lines.push("")
5758
for (const [cmd, desc] of commands) {
5859
lines.push(` ${cmd.padEnd(colWidth)}${desc}`)
@@ -66,7 +67,7 @@ export async function handleHelpCommand(ctx: HelpCommandContext): Promise<void>
6667
const { client, state, logger, sessionId, messages } = ctx
6768

6869
const { config } = ctx
69-
const message = formatHelpMessage(!!state.manualMode, config)
70+
const message = formatHelpMessage(state, config)
7071

7172
const params = getCurrentParams(state, messages, logger)
7273
await sendIgnoredMessage(client, sessionId, message, params, logger)

lib/hooks.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { handleSweepCommand } from "./commands/sweep"
2121
import { handleManualToggleCommand, handleManualTriggerCommand } from "./commands/manual"
2222
import { handleDecompressCommand } from "./commands/decompress"
2323
import { handleRecompressCommand } from "./commands/recompress"
24+
import { type HostPermissionSnapshot } from "./host-permissions"
25+
import { compressPermission, syncCompressPermissionState } from "./shared-utils"
2426
import { ensureSessionInitialized } from "./state/state"
2527
import { cacheSystemPromptTokens } from "./ui/utils"
2628
import type { PromptStore } from "./prompts/store"
@@ -91,7 +93,12 @@ export function createSystemPromptHandler(
9193
return
9294
}
9395

94-
if (config.compress.permission === "deny") {
96+
const effectivePermission =
97+
input.sessionID && state.sessionId === input.sessionID
98+
? compressPermission(state, config)
99+
: config.compress.permission
100+
101+
if (effectivePermission === "deny") {
95102
return
96103
}
97104

@@ -116,10 +123,13 @@ export function createChatMessageTransformHandler(
116123
logger: Logger,
117124
config: PluginConfig,
118125
prompts: PromptStore,
126+
hostPermissions: HostPermissionSnapshot,
119127
) {
120128
return async (input: {}, output: { messages: WithParts[] }) => {
121129
await checkSession(client, state, logger, output.messages, config.manualMode.enabled)
122130

131+
syncCompressPermissionState(state, config, hostPermissions, output.messages)
132+
123133
if (state.isSubAgent && !config.experimental.allowSubAgents) {
124134
return
125135
}
@@ -156,6 +166,7 @@ export function createCommandExecuteHandler(
156166
logger: Logger,
157167
config: PluginConfig,
158168
workingDirectory: string,
169+
hostPermissions: HostPermissionSnapshot,
159170
) {
160171
return async (
161172
input: { command: string; sessionID: string; arguments: string },
@@ -180,6 +191,10 @@ export function createCommandExecuteHandler(
180191
config.manualMode.enabled,
181192
)
182193

194+
syncCompressPermissionState(state, config, hostPermissions, messages)
195+
196+
const effectivePermission = compressPermission(state, config)
197+
183198
const args = (input.arguments || "").trim().split(/\s+/).filter(Boolean)
184199
const subcommand = args[0]?.toLowerCase() || ""
185200
const subArgs = args.slice(1)
@@ -217,7 +232,7 @@ export function createCommandExecuteHandler(
217232
throw new Error("__DCP_MANUAL_HANDLED__")
218233
}
219234

220-
if (subcommand === "compress" && config.compress.permission !== "deny") {
235+
if (subcommand === "compress" && effectivePermission !== "deny") {
221236
const userFocus = subArgs.join(" ").trim()
222237
const prompt = await handleManualTriggerCommand(commandCtx, "compress", userFocus)
223238
if (!prompt) {
@@ -238,15 +253,15 @@ export function createCommandExecuteHandler(
238253
return
239254
}
240255

241-
if (subcommand === "decompress" && config.compress.permission !== "deny") {
256+
if (subcommand === "decompress" && effectivePermission !== "deny") {
242257
await handleDecompressCommand({
243258
...commandCtx,
244259
args: subArgs,
245260
})
246261
throw new Error("__DCP_DECOMPRESS_HANDLED__")
247262
}
248263

249-
if (subcommand === "recompress" && config.compress.permission !== "deny") {
264+
if (subcommand === "recompress" && effectivePermission !== "deny") {
250265
await handleRecompressCommand({
251266
...commandCtx,
252267
args: subArgs,

lib/host-permissions.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
export type PermissionAction = "ask" | "allow" | "deny"
2+
3+
export type PermissionValue = PermissionAction | Record<string, PermissionAction>
4+
5+
export type PermissionConfig = Record<string, PermissionValue> | undefined
6+
7+
export interface HostPermissionSnapshot {
8+
global: PermissionConfig
9+
agents: Record<string, PermissionConfig>
10+
}
11+
12+
type PermissionRule = {
13+
permission: string
14+
pattern: string
15+
action: PermissionAction
16+
}
17+
18+
const wildcardMatch = (value: string, pattern: string): boolean => {
19+
const normalizedValue = value.replaceAll("\\", "/")
20+
let escaped = pattern
21+
.replaceAll("\\", "/")
22+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
23+
.replace(/\*/g, ".*")
24+
.replace(/\?/g, ".")
25+
26+
if (escaped.endsWith(" .*")) {
27+
escaped = escaped.slice(0, -3) + "( .*)?"
28+
}
29+
30+
const flags = process.platform === "win32" ? "si" : "s"
31+
return new RegExp(`^${escaped}$`, flags).test(normalizedValue)
32+
}
33+
34+
const getPermissionRules = (permissionConfigs: PermissionConfig[]): PermissionRule[] => {
35+
const rules: PermissionRule[] = []
36+
for (const permissionConfig of permissionConfigs) {
37+
if (!permissionConfig) {
38+
continue
39+
}
40+
41+
for (const [permission, value] of Object.entries(permissionConfig)) {
42+
if (value === "ask" || value === "allow" || value === "deny") {
43+
rules.push({ permission, pattern: "*", action: value })
44+
continue
45+
}
46+
47+
for (const [pattern, action] of Object.entries(value)) {
48+
if (action === "ask" || action === "allow" || action === "deny") {
49+
rules.push({ permission, pattern, action })
50+
}
51+
}
52+
}
53+
}
54+
return rules
55+
}
56+
57+
export const compressDisabledByOpencode = (...permissionConfigs: PermissionConfig[]): boolean => {
58+
const match = getPermissionRules(permissionConfigs).findLast((rule) =>
59+
wildcardMatch("compress", rule.permission),
60+
)
61+
62+
return match?.pattern === "*" && match.action === "deny"
63+
}
64+
65+
export const resolveEffectiveCompressPermission = (
66+
basePermission: PermissionAction,
67+
hostPermissions: HostPermissionSnapshot,
68+
agentName?: string,
69+
): PermissionAction => {
70+
if (basePermission === "deny") {
71+
return "deny"
72+
}
73+
74+
return compressDisabledByOpencode(
75+
hostPermissions.global,
76+
agentName ? hostPermissions.agents[agentName] : undefined,
77+
)
78+
? "deny"
79+
: basePermission
80+
}
81+
82+
export const hasExplicitToolPermission = (
83+
permissionConfig: PermissionConfig,
84+
tool: string,
85+
): boolean => {
86+
return permissionConfig ? Object.hasOwn(permissionConfig, tool) : false
87+
}

lib/messages/inject/inject.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Logger } from "../../logger"
33
import type { PluginConfig } from "../../config"
44
import type { RuntimePrompts } from "../../prompts/store"
55
import { formatMessageIdTag } from "../../message-ids"
6-
import { getLastUserMessage } from "../../shared-utils"
6+
import { compressPermission, getLastUserMessage } from "../../shared-utils"
77
import { saveSessionState } from "../../state/persistence"
88
import {
99
appendIdToTool,
@@ -30,7 +30,7 @@ export const injectCompressNudges = (
3030
messages: WithParts[],
3131
prompts: RuntimePrompts,
3232
): void => {
33-
if (config.compress.permission === "deny") {
33+
if (compressPermission(state, config) === "deny") {
3434
return
3535
}
3636

@@ -139,7 +139,7 @@ export const injectMessageIds = (
139139
config: PluginConfig,
140140
messages: WithParts[],
141141
): void => {
142-
if (config.compress.permission === "deny") {
142+
if (compressPermission(state, config) === "deny") {
143143
return
144144
}
145145

lib/shared-utils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { PluginConfig } from "./config"
2+
import { type HostPermissionSnapshot, resolveEffectiveCompressPermission } from "./host-permissions"
13
import { SessionState, WithParts } from "./state"
24
import { isIgnoredUserMessage } from "./messages/utils"
35

@@ -25,3 +27,24 @@ export const getLastUserMessage = (
2527
}
2628
return null
2729
}
30+
31+
export const compressPermission = (
32+
state: SessionState,
33+
config: PluginConfig,
34+
): "ask" | "allow" | "deny" => {
35+
return state.compressPermission ?? config.compress.permission
36+
}
37+
38+
export const syncCompressPermissionState = (
39+
state: SessionState,
40+
config: PluginConfig,
41+
hostPermissions: HostPermissionSnapshot,
42+
messages: WithParts[],
43+
): void => {
44+
const activeAgent = getLastUserMessage(messages)?.info.agent
45+
state.compressPermission = resolveEffectiveCompressPermission(
46+
config.compress.permission,
47+
hostPermissions,
48+
activeAgent,
49+
)
50+
}

lib/state/state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export function createSessionState(): SessionState {
6666
sessionId: null,
6767
isSubAgent: false,
6868
manualMode: false,
69+
compressPermission: undefined,
6970
pendingManualTrigger: null,
7071
prune: {
7172
tools: new Map<string, number>(),
@@ -100,6 +101,7 @@ export function resetSessionState(state: SessionState): void {
100101
state.sessionId = null
101102
state.isSubAgent = false
102103
state.manualMode = false
104+
state.compressPermission = undefined
103105
state.pendingManualTrigger = null
104106
state.prune = {
105107
tools: new Map<string, number>(),

lib/state/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export interface SessionState {
8484
sessionId: string | null
8585
isSubAgent: boolean
8686
manualMode: false | "active" | "compress-pending"
87+
compressPermission: "ask" | "allow" | "deny" | undefined
8788
pendingManualTrigger: PendingManualTrigger | null
8889
prune: Prune
8990
nudges: Nudges

0 commit comments

Comments
 (0)