Skip to content
Open
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ OpenCode V2.0.3 does not expose native permission-request creation to server
plugins. Effective `allow` executes ACP tools; `deny` does not advertise them
and blocks execution; `ask` fails closed before state mutation and returns an
actionable result telling you to choose `allow` or `deny`. `ask` does not open
an interactive prompt on V2. The V1 permission behavior is unchanged.
an interactive prompt on V2. On V2, agent permission rules resolve per real
tool name (`compress`, `decompress`, `search_context`, `acp_status`,
`acp_context_recap`) using OpenCode's ordered last-match semantics, so a rule
targeting one ACP tool does not affect the others. The V1 permission behavior
is unchanged.

OpenCode V2.0.3 cannot rewrite completed assistant text before persistence or
display. ACP instead sanitizes hallucinated ACP/DCP tags when historical
Expand Down
5 changes: 4 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,10 @@ V1 和 V2 使用相同的 ACP 功能面:五个工具 `compress`、`decompress`
OpenCode V2.0.3 不向 server plugin 暴露创建原生权限请求的能力。有效权限为
`allow` 时工具正常执行;为 `deny` 时工具不会提供给模型且会阻止执行;为 `ask`
时,ACP 会在修改状态前安全失败,并返回可操作的结果,要求选择 `allow` 或
`deny`。V2 不会为 `ask` 打开交互式提示;V1 的权限行为不变。
`deny`。V2 不会为 `ask` 打开交互式提示。在 V2 中,agent 权限规则按真实工具名
(`compress`、`decompress`、`search_context`、`acp_status`、
`acp_context_recap`)解析,采用 OpenCode 的有序 last-match 语义,因此针对
某个 ACP 工具的规则不会影响其他工具。V1 的权限行为不变。

OpenCode V2.0.3 没有在 assistant 完成文本持久化或显示前重写它的能力。因此,
ACP 只会在历史 assistant 文本重新组装到发往模型的上下文时,清理模型臆造的
Expand Down
71 changes: 71 additions & 0 deletions devlog/2026-09-29_v2-per-tool-permission/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# REQ - V2 per-tool permission resolution

- Task ID: `2026-09-29_v2-per-tool-permission`
- Home Repo: `opencode-acp`
- Created: 2026-09-29
- Status: InProgress
- Priority: P1
- Owner: ranxianglei
- References: https://github.com/ranxianglei/opencode-acp/issues/459

## 1. Background & Problem Statement

- **Context**: On the V2 line the five ACP tools (`compress`, `decompress`,
`search_context`, `acp_status`, `acp_context_recap`) are registered as direct
model tools. Host permission rules (OpenCode V2 agent permissions) are ordered
`{action, resource, effect}` entries where `action` is matched against the
real tool name in the host's evaluate pipeline.
- **Current behavior (symptom)**: The internal resolver hard-codes the literal
`"compress"` — `resolveEffectiveCompressPermission()` calls
`resolveV2Permission(v2Rules, "compress")` regardless of which tool is being
executed, and every tool declares `options.permission: "compress"`. All five
tools therefore share one permission decision. A granular ruleset like
`[{action:"compress",effect:"ask"},{action:"*",effect:"deny"}]` silences four
read-only tools with a generic refusal while `compress` itself is never
actually asked; the reverse ruleset denies all five tools.
- **Expected behavior**: Each tool resolves its own decision from the host
ruleset using its real tool name (OpenCode's ordered last-match semantics).
Refusal messages and result metadata name the actual tool.
- **Impact**: MAJOR — per-tool permission control does not exist on V2; silent
misbehavior visible to users only as generic refusals.

## 2. Reproduction (if applicable)

- **Environment**: OpenCode V2 (>=2.0.3), agent permission rules in opencode.json.
- **Minimal reproduction steps**:
1) Set agent rules `[{action:"compress",effect:"ask"}]`.
2) Call `acp_status` (or any of the other four tools).
3) Observed: refusal message about "the `compress` permission" even though no
rule targets `acp_status`. Expected: normal execution.
- **Relevant configuration**: ACP `compress.permission` left at default `allow`;
behavior driven purely by host rules.

## 3. Constraints & Non-Goals

- **Constraints**:
- Backward compatibility: for `compress` itself, refusal message text must stay
byte-identical; V1 permission path untouched; `state.compressPermission`
nudge-gating semantics unchanged (still compress-specific by design);
OpenCode's ordered last-match rule semantics preserved (not redefined);
fail-closed `ask` on V2 preserved (OpenCode 2.0.3 exposes no native
permission-request creation API to server plugins — DESIGN §8.1).
- Performance requirements: none (one string comparison per tool call).
- **Non-Goals** (explicitly out of scope):
- Routing `ask` to an interactive prompt (host capability does not exist in
this runtime).
- Changing what ACP's own `compress.permission` config key gates (it remains a
global gate over ACP tools).
- V1 per-tool permission behavior.

## 4. Acceptance Criteria (must be testable)

- **Correctness**:
- [x] A rule targeting `compress` (deny or ask) does not affect
`decompress` / `search_context` / `acp_status` / `acp_context_recap`.
- [x] A rule targeting any non-compress tool does not affect `compress`.
- [x] `options.permission` declared per tool equals the real tool name.
- [x] Deny/ask result metadata carries the real tool name; messages name it.
- [x] Ordered last-match semantics preserved (later matching rule wins).
- [x] Pre-state-acquisition safety preserved (no mutation on deny/ask paths).
- **Performance / Stability**:
- [x] Full test suite + typecheck + build pass on Node 22/24.
70 changes: 70 additions & 0 deletions devlog/2026-09-29_v2-per-tool-permission/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# WORKLOG - V2 per-tool permission resolution

- Task ID: `2026-09-29_v2-per-tool-permission`
- Home Repo: `opencode-acp`
- Status: InProgress
- Updated: 2026-09-29

## 1. Summary

- **What was done** (1–3 sentences): V2 tool permission resolution now keys on
the real tool name instead of the hard-coded literal `"compress"`. The inert
`options.permission` declaration now carries each tool's own name, and the
fail-closed ask/deny messages plus result metadata name the actual tool.
- **Why** (1–3 sentences): Issue #459 — all five V2 tools inherited one shared
decision, so granular host rulesets produced the opposite of their intended
effect (e.g. an `ask` on `compress` refused four unrelated read-only tools).
- **Behavior / compatibility changes**: Yes —
- Old → new: every V2 tool resolved under action `"compress"` → each tool
resolves under its own name (`decompress`, `search_context`, `acp_status`,
`acp_context_recap` no longer inherit `compress` rules). Rules written
against those names now take effect; rules that previously leaked onto
them no longer do.
- Refusal metadata `permission` field: `"compress"` for all tools → the real
tool name. Message text for non-compress tools now names the tool
(previously hard-coded "the `compress` permission").
- Unchanged on purpose: behavior for `compress` itself is byte-identical
(message text, metadata value, resolution); ACP's own
`compress.permission` config gate remains a global gate over ACP tools;
V1 path and nudge gating (`state.compressPermission`) untouched; OpenCode
ordered last-match semantics preserved; fail-closed `ask` preserved
(no native permission-request API in OpenCode 2.0.3).
- **Risk level**: Low (one comparison argument + message templating; full suite
green).

## 2. Change Log

### Commits

| Commit | Description |
|--------|-------------|
| `d719eefb` | fix(v2): resolve host permission rules per real tool name |

### Key Files

- `lib/host-permissions.ts` — `resolveEffectiveCompressPermission()` gains an
optional `toolName` parameter (default `"compress"`) used only by the V2
ordered-rules branch; legacy V1 branch unchanged.
- `lib/v2/tools.ts` — `resolveToolPermission()` receives the definition's real
name; `options.permission` set per tool; ask/deny refusal messages and result
metadata made dynamic per tool name.
- `tests/v2-tools.test.ts` — updated four tests that pinned the hard-coded
`"compress"` behavior (rules now target the test tool's own name; `options`
assertion per tool name); added regression suite covering deny/ask isolation
across all five tool names including the issue's failure scenarios.
- `README.md` — one sentence documenting per-tool rule resolution on V2.

## 3. Design & Implementation Notes

- `options.permission` kept (not deleted) but set to the real tool name: live
measurement showed the field never reaches the host evaluate pipeline today,
but if the host ever honors it as the evaluation action, per-name values make
granular host rules work end-to-end instead of mis-declaring "compress".
- The issue's literal example `[{action:"compress",effect:"ask"},{action:"*",effect:"deny"}]`
resolves to deny-for-all under OpenCode's ordered last-match semantics (the
later `*` rule wins for every action). ACP deliberately does not redefine
platform ordering; users express intent by ordering the specific rule last.
Tests use unambiguous orderings and assert per-name independence directly.
- `lib/compress/pipeline.ts` and `lib/compress/decompress.ts` call
`toolCtx.ask({permission:"compress"})` — that is the V1 host-permission path
(no-op on V2 via `toSharedContext`'s empty `ask`) and stays as-is.
13 changes: 12 additions & 1 deletion lib/host-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,29 @@ export const compressDisabledByOpencode = (...permissionConfigs: PermissionConfi
return match?.pattern === "*" && match.action === "deny"
}

/**
* Resolve the effective ACP permission for one tool.
*
* `toolName` is only consulted on the V2 branch, where ordered agent rules
* match by real tool name (`compress`, `decompress`, `search_context`,
* `acp_status`, `acp_context_recap`). It defaults to `"compress"` for the
* compress-specific callers (nudge gating); the legacy V1 branch stays keyed
* to the `compress` permission entry because V1 host configs have no
* per-tool entries for ACP tools.
*/
export const resolveEffectiveCompressPermission = (
basePermission: PermissionAction,
hostPermissions: HostPermissionSnapshot,
agentName?: string,
toolName: string = "compress",
): PermissionAction => {
if (basePermission === "deny") {
return "deny"
}

const v2Rules = agentName ? hostPermissions.v2Agents?.[agentName] : undefined
if (v2Rules) {
const permission = resolveV2Permission(v2Rules, "compress")
const permission = resolveV2Permission(v2Rules, toolName)
return permission ?? basePermission
}

Expand Down
36 changes: 24 additions & 12 deletions lib/v2/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ export const V2_ACP_TOOL_NAMES = [

type V2ToolContent = Exclude<NonNullable<V2ToolResult["content"]>, string>[number]

const PERMISSION_ASK_MESSAGE =
"ACP cannot request an interactive permission on OpenCode V2.0.3. Set the active agent's `compress` permission to `allow` or `deny`, then retry."
const PERMISSION_DENY_MESSAGE =
"ACP tool execution is disabled by the active agent or ACP configuration. Choose `allow` for the `compress` permission to enable it."
const permissionAskMessage = (toolName: string) =>
`ACP cannot request an interactive permission on OpenCode V2.0.3. Set the active agent's \`${toolName}\` permission to \`allow\` or \`deny\`, then retry.`
const permissionDenyMessage = (toolName: string) =>
`ACP tool execution is disabled by the active agent or ACP configuration. Choose \`allow\` for the \`${toolName}\` permission to enable it.`
const SUBAGENT_DENY_MESSAGE =
"ACP direct tools are disabled for child sessions when `allowSubAgents` is false."
const LIFECYCLE_DENY_MESSAGE = "ACP is shutting down; this operation was not executed."
Expand Down Expand Up @@ -95,6 +95,7 @@ async function resolveToolPermission(
host: V2HostAdapter,
hostPermissions: HostPermissionSnapshot,
context: V2ToolContext,
toolName: string,
isActive: () => boolean = () => true,
): Promise<"ask" | "allow" | "deny"> {
const basePermission = factoryCtx.config.compress.permission
Expand All @@ -103,7 +104,12 @@ async function resolveToolPermission(
}

if (!host.agentPermissions) {
return resolveEffectiveCompressPermission(basePermission, hostPermissions, context.agent)
return resolveEffectiveCompressPermission(
basePermission,
hostPermissions,
context.agent,
toolName,
)
}

try {
Expand All @@ -113,7 +119,12 @@ async function resolveToolPermission(
...(hostPermissions.v2Agents ?? {}),
[context.agent]: rules,
}
return resolveEffectiveCompressPermission(basePermission, hostPermissions, context.agent)
return resolveEffectiveCompressPermission(
basePermission,
hostPermissions,
context.agent,
toolName,
)
} catch {
// V2 has no supported permission-request fallback. Unknown policy is
// therefore denied before the shared definition or session guard runs.
Expand Down Expand Up @@ -202,7 +213,7 @@ export function createV2Tool<Schema extends AnyToolSchema>(
name: definition.name,
description: definition.description,
input: definition.schema,
options: { codemode: false, permission: "compress" },
options: { codemode: false, permission: definition.name },
execute: async (rawInput, context) => {
const executeBody = async (isActive: () => boolean = () => true) => {
if (!isActive())
Expand Down Expand Up @@ -238,14 +249,15 @@ export function createV2Tool<Schema extends AnyToolSchema>(
host,
hostPermissions,
context,
definition.name,
isActive,
)
} catch (error) {
return errorResult(
"ACP could not resolve the active agent permission; execution was blocked.",
{
acpPermission: "deny",
permission: "compress",
permission: definition.name,
actionable: "choose allow or deny",
error: errorMessage(error),
},
Expand All @@ -254,16 +266,16 @@ export function createV2Tool<Schema extends AnyToolSchema>(
if (!isActive())
return errorResult(LIFECYCLE_DENY_MESSAGE, { acpError: "inactive" })
if (permissionResult === "deny") {
return errorResult(PERMISSION_DENY_MESSAGE, {
return errorResult(permissionDenyMessage(definition.name), {
acpPermission: "deny",
permission: "compress",
permission: definition.name,
actionable: "choose allow or deny",
})
}
if (permissionResult === "ask") {
return errorResult(PERMISSION_ASK_MESSAGE, {
return errorResult(permissionAskMessage(definition.name), {
acpPermission: "ask",
permission: "compress",
permission: definition.name,
actionable: "choose allow or deny",
})
}
Expand Down
Loading