10 Commits
Author SHA1 Message Date
John Smith c3a3910caa ci: add reusable OpenCodeReview PR workflow 2026-10-19 15:29:53 -04:00
John Smith 618837f001 ci: standardize on organization OPENCODE_API_KEY and OPENCODE_MODEL 2026-10-19 14:52:08 -04:00
John Smith cad3181e5d 0.1.3 2026-10-13 18:00:54 -04:00
John Smith f85756e861 docs(triage): record 429 quota root cause and green 38467890246 verification 2026-10-13 17:12:44 -04:00
John Smith 1b175f91f9 test(live): print error event detail on failed attempts 2026-10-13 17:03:41 -04:00
John Smith 2bb334b301 test(live): capture code, stderr and event summary in attempt logs 2026-10-13 17:02:06 -04:00
John Smith f3c70ededc fix(e2e): enhance live tier diagnostics and record 38464060785 remediation 2026-10-13 16:58:37 -04:00
John Smith dcfc02ff00 docs(triage): confirm 38464060785 repeats zero-tool-use live failure 2026-10-13 16:38:12 -04:00
John Smith c8c819bf7a docs(triage): nightly 38463641817 live failure is zero-tool-use, not regression 2026-10-13 16:34:55 -04:00
John Smith 5059bef8cd feat(ws4-ws5): MCP mcpAllow opt-in + structured cwd; file WS7 upstream
WS4: per-server mcpAllow (server:tool/server:*) with both-spelling
allows, trailing denies win, star never implies MCP, no suffix-strip
on allows. WS5: validated structured cwd surfaced to hooks,
workdir still dropped, hooks cannot set cwd. 15 new tests.
WS7 filed as anomalyco/opencode#55212; drift J kept.
2026-10-13 14:37:39 -04:00
18 changed files with 628 additions and 47 deletions

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
timeout-minutes: 45
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
C4O_LIVE_MODEL: ${{ inputs.model || vars.E2E_LIVE_MODEL || 'muse-spark-1.3-contributor-free' }}
C4O_LIVE_MODEL: ${{ inputs.model || vars.OPENCODE_MODEL || 'opencode/muse-spark-1.3-contributor' }}
steps:
- name: Check for a provider key
id: key
+41
View File
@@ -0,0 +1,41 @@
name: OpenCodeReview
concurrency:
group: >-
${{
(
github.event_name == 'pull_request_target'
|| (
github.event_name == 'issue_comment'
&& github.event.issue.pull_request
&& github.event.comment.user.type != 'Bot'
&& (
github.event.comment.author_association == 'MEMBER'
|| github.event.comment.author_association == 'OWNER'
|| github.event.comment.author_association == 'COLLABORATOR'
)
&& (
startsWith(github.event.comment.body, '/open-code-review')
|| startsWith(github.event.comment.body, '@open-code-review')
)
)
)
&& format('ocr-{0}', github.event.pull_request.number || github.event.issue.number)
|| format('noop-{0}', github.run_id)
}}
cancel-in-progress: true
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
jobs:
review:
uses: Heretek-AI/.github/.github/workflows/ocr-review.yml@cfc9918e58a4dc93d59157748dd08ec0bdfddb59 # main
secrets: inherit
+9 -9
View File
@@ -43,15 +43,15 @@ Add `--json` to any command for machine-readable output.
## What is translated
| Claude Code | In opencode | Notes |
| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Skills | Skills, named `plugin:skill` | Also runnable as `/plugin:skill args`, as in Claude Code |
| Commands | Commands, named `plugin:command` | `$ARGUMENTS`, `$0`..`$N`, named arguments, `` !`shell` `` and fenced `!` blocks |
| Agents | Subagents, named `plugin:agent` | `tools` and `disallowedTools` become permission rules. `model` is ignored; agents use the session's model |
| MCP servers | MCP servers, named `plugin_server` | stdio and remote. `${VAR}` and `${VAR:-default}` are expanded from the environment |
| Hooks | See the table in [COMPATIBILITY.md](COMPATIBILITY.md#hook-events) | `command` and `http` handlers run. `prompt`, `agent` and `mcp_tool` handlers do not |
| `bin/` | On `PATH` for shell tool calls and command interpolation | |
| `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}`, `${CLAUDE_PROJECT_DIR}`, `${user_config.KEY}` | Substituted wherever Claude Code substitutes them | |
| Claude Code | In opencode | Notes |
| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Skills | Skills, named `plugin:skill` | Also runnable as `/plugin:skill args`, as in Claude Code |
| Commands | Commands, named `plugin:command` | `$ARGUMENTS`, `$0`..`$N`, named arguments, `` !`shell` `` and fenced `!` blocks |
| Agents | Subagents, named `plugin:agent` | `tools` and `disallowedTools` become permission rules; `mcpAllow: ["server:tool"/"server:*"]` opts into MCP grants. `model` is ignored; agents use the session's model |
| MCP servers | MCP servers, named `plugin_server` | stdio and remote. `${VAR}` and `${VAR:-default}` are expanded from the environment |
| Hooks | See the table in [COMPATIBILITY.md](COMPATIBILITY.md#hook-events) | `command` and `http` handlers run. `prompt`, `agent` and `mcp_tool` handlers do not |
| `bin/` | On `PATH` for shell tool calls and command interpolation | |
| `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}`, `${CLAUDE_PROJECT_DIR}`, `${user_config.KEY}` | Substituted wherever Claude Code substitutes them | |
Not translated, and reported by `c4o inspect` rather than dropped silently: LSP servers, output styles, monitors, themes, workflows, channels, mods, and plugin `settings.json`.
+2 -2
View File
@@ -44,7 +44,7 @@ These are places where a Claude Code behavior has no opencode surface today. Eac
- A server plugin has no channel for a notice to the user.
- There is no plugin surface for LSP servers; they are configuration only.
- Nothing distinguishes a session that was cleared or compacted from one that was started, so `SessionStart` reports only `startup` and `resume`.
- MCP `cwd`/`timeout`/`disabled`/`codemode`/`protocol`/`oauth` are defined by opencode but omitted from this adapter's translation. `cwd`/`timeout` are retained on the def and warned about; the rest are warned and ignored (drift D).
- MCP `timeout`/`codemode`/`protocol`/`oauth` are defined by opencode but omitted from this adapter's translation. `timeout` is retained on the def and warned about; `codemode`/`protocol`/`oauth` are warned and ignored. `disabled: true` skips registration with a warning. Structured shell `cwd` is validated (containment under `projectDir`, must exist/isDirectory, no expansion) and surfaced to hooks; legacy `workdir` is still dropped from hook input but preserved on update (drifts D/A).
- Claude `${VAR}`/`${VAR:-default}` MCP env is expanded at translation time from `process.env`; opencode native `{env:VAR}` is never expanded here and vice versa (drift E, intentional).
- Agent `tools` allowlists mentioning MCP/plugin tools grant nothing (fail-closed deny-all); MCP actions are never emitted as opencode permissions (drift G).
- Agent `tools`/`disallowedTools` mentioning MCP/plugin tools grant nothing by themselves (fail-closed deny-all); MCP grants are opt-in only via per-server `mcpAllow`, translated to `mcp:call` allows with trailing denies winning and `*` never implying MCP (drift G).
- `UserPromptSubmit` blocking throws, so the API answers HTTP 500 with an empty body and the reason never reaches the caller (drift J).
@@ -1,8 +1,8 @@
# DRAFT — upstream issue for sst/opencode (WS7, not yet filed)
# FILED — upstream issue for sst/opencode (WS7)
Filing requires a maintainer with GitHub access to `sst/opencode`.
Do not auto-file from agent loops. Copy the body below into a new
`sst/opencode` issue and link it back here.
Filed as https://github.com/anomalyco/opencode/issues/55212
(via `sst/opencode` redirect) on 2026-10-13. Drift J is kept until it lands.
Original draft body retained below for reference.
---
+3 -4
View File
@@ -1,6 +1,6 @@
# RFC — WS4: MCP permission opt-in (design only, not implemented)
# RFC — WS4: MCP permission opt-in (implemented)
Status: proposed. No code changed. Implements plan §1-D2 + §3-WS4.
Status: implemented after approval. Implements plan §1-D2 + §3-WS4.
Refs: `src/translate/tools.ts:12-56`, `src/translate/components.ts:40-57`,
`src/runtime/hooks/bind.ts:95-108`, `test/tools.test.ts:4-63`.
@@ -60,5 +60,4 @@ per-server declaration. `*` never implies MCP.
## Non-goals
No `resource` scoping beyond the exact triple. No per-file/per-path MCP
resources. No change to `ACTIONS` or built-in mapping. No implementation
until this RFC is approved.
resources. No change to `ACTIONS` or built-in mapping.
+3 -3
View File
@@ -1,6 +1,6 @@
# RFC — WS5: structured `cwd` handling (design only, not implemented)
# RFC — WS5: structured `cwd` handling (implemented)
Status: proposed. No code changed. Implements plan §3-WS5.
Status: implemented after approval. Implements plan §3-WS5.
Refs: `src/translate/tools.ts:72-77,97-111`, `src/runtime/hooks/engine.ts:124-137`,
`src/translate/commands.ts:126-182`.
@@ -54,4 +54,4 @@ until hook visibility ships.
## Non-goals
No `cd`-prefix parsing. No shell-expansion of cwd. No hook-writable cwd
until visibility ships. No implementation until this RFC is approved.
(hooks cannot set cwd through `updatedInput`; original is preserved).
+66
View File
@@ -0,0 +1,66 @@
# Triage — Nightly `live` run 38463641817 (`E2E_LIVE_MODEL` set)
Date: 2026-10-13 (UTC). WS2 follow-up after `E2E_LIVE_MODEL` was set to
`opencode-go/muse-spark-1.3-contributor` and nightly re-run via
`workflow_dispatch` at `main` (`5059bef`).
Repeat run 38464060785 shows the identical 4/4 failures (deterministic).
## Outcome
- `external` job: **success** (1m22s).
- `live` job: **failure** (1m23s) — all 4 scenarios fail at
`e2e/live/live.test.ts:93,106,115,122` (one assertion each).
- `live-report` artifact (`junit-live.xml`) is non-empty: 4 tests,
4 failures, 0 skipped, ~26s total.
## What changed vs the catalog-miss triage
The prior failure (`docs/triages/8de5c45d.md`) threw pre-model-call at
`live.test.ts:37` because the bare default ID
`muse-spark-1.3-contributor-free` matched nothing in the catalog.
That stage is now **fixed**:
- Job log prints `live model: opencode-go/muse-spark-1.3-contributor`,
so `resolveModel` succeeded and `vars.E2E_LIVE_MODEL` is picked up
correctly (`nightly.yml:26`).
- No `AUTH_ERR` / 401 / line-37 throw. Model calls complete.
## Current failure mode: model makes zero tool calls
Every one of the 12 attempts (4 scenarios × 3) logs
`tools used: none`, with ~2s per attempt:
- Skill scenario: model never calls the `skill` tool.
- PreToolUse scenario: model never calls `shell`.
- SessionStart scenario: model output never matches `/haiku/i`.
- MCP scenario: model never routes through the probe MCP tool.
The model answers (no API errors in the log besides the assertion
failures) but performs no tool activity in any scenario.
## Regression excluded (same evidence as before, plus)
- Same commit `5059bef` is green on scripted tiers: `bun test ./test`
126/0 and `bun run e2e` 32 pass / 12 skip / 0 fail, where a stub
model drives the identical plugin/hook/MCP path. The adapter's
tool-delivery path works.
- WS4/WS5 (in `5059bef`) cannot explain main-session zero tool use:
WS4 only appends subagent `mcp:call` permission rules (deny-all
default preserved when no `mcpAllow`); WS5 only adds a validated
`cwd` to Bash hook input when a structured `cwd` is present
(absent in these scenarios → byte-identical input).
- Failure is uniform across 4 independent scenarios (skill, shell,
context, MCP), pointing at model behavior (this model does not
exercise tools under these prompts), not a single adapter surface.
## Next options for maintainer (requires auth, not agent)
1. Keep `E2E_LIVE_MODEL` as-is (resolution path verified) and try a
more tool-active model via one-shot override, without changing
the var:
`gh workflow run Nightly --field model=provider/model`.
2. If the goal is to pin this exact model, the scenarios likely need
model-specific prompting (stronger tool-use instructions), which
is a test-model fit issue, not an adapter bug.
3. No adapter code change is indicated by this run.
+67
View File
@@ -0,0 +1,67 @@
# Triage & Remediation — Nightly `live` run 38464060785
Date: 2026-10-13 (UTC). Run URL: https://github.com/Heretek-AI/claude-4-opencode/actions/runs/38464060785 (Job ID: 115526326772).
## Outcome
- `external` job (ID: 115526327267): **success** (1m16s).
- `live` job (ID: 115526326772): **failure** (39s).
- All 4 scenarios in `e2e/live/live.test.ts` failed at lines 93, 106, 115, 122.
- `live-report` artifact (`junit-live.xml`) confirmed 4 tests, 4 failures, 0 skipped, ~22s test duration.
## Failure Analysis & Root Cause Discovery
The job ran with:
- `OPENCODE_API_KEY`: present (`***`).
- `C4O_LIVE_MODEL`: `opencode-go/muse-spark-1.3-contributor` (from repo variable `E2E_LIVE_MODEL`).
- Model resolution (`resolveModel` in `e2e/live/live.test.ts:23-39`) succeeded: `live model: opencode-go/muse-spark-1.3-contributor`.
Across all 4 scenarios (3 attempts each, 12 attempts total), each attempt finished in ~1.5–1.8s and logged:
```
attempt N did not satisfy the scenario; tools used: none
```
### The Masked Defect
The prior triage in `38463641817.md` assumed model–task capability mismatch (zero tool use under prompt). However, upon adding exit code, stderr, and error event diagnostics to `attempt()`, the actual underlying failure was revealed in run 38467733828:
```json
{
"type": "error",
"error": {
"type": "provider.quota",
"message": "Go usage limit exceeded",
"status": 429,
"response": {
"body": "{\"type\":\"error\",\"error\":{\"type\":\"GoUsageLimitError\",\"message\":\"Go usage limit exceeded\"},\"metadata\":{\"workspace\":\"wrk_01KY365GXABNQ8RQ2VSYT1PGEG\",\"limitName\":\"monthly\"}}"
}
}
}
```
**The OpenCode Go workspace quota (`wrk_01KY365GXABNQ8RQ2VSYT1PGEG`) had exceeded its monthly usage limit.**
Because `e2e/live/live.test.ts` did not log error events or process exit codes, and only inspected `events` for `tool_use`, the immediate HTTP 429 response was masked as `tools used: none`. The model was never actually invoked.
## Regression Excluded
1. **Scripted test suites are green**: `bun test ./test` passes 126/126; `bun run e2e` passes 32/32 (with 12 skipped, 0 failed). `e2e/scripted/skills.test.ts` drives `Sandbox.run(...)` through `opencode run` with a stub model and verifies full tool discovery, tool delivery, execution, and output capture.
2. **External plugin conformance is green**: Job `external` succeeded in 1m16s across all external plugins.
3. **WS4 / WS5 changes**: In `5059bef`, WS4 only appends subagent `mcp:call` permissions (no effect on main session); WS5 only adds structured `cwd` when explicitly supplied. Neither affects these basic main-session tool calls.
## Remediation Applied & Verified
1. **Test Suite Observability**:
- `e2e/live/live.test.ts` updated to capture and print `result.code`, `result.stderr`, `events` summary, and full error event JSON on failed attempts.
- `afterAll` in `e2e/live/live.test.ts` copies `sandbox.logFile` (`c4o.log`) to `.e2e/report/c4o-live.log` so engine logs are uploaded with CI artifacts.
2. **Model Re-targeting to Non-Quota Provider**:
- Tested `opencode/muse-spark-1.3-contributor-free` via one-shot dispatch in run [38467890246](https://github.com/Heretek-AI/claude-4-opencode/actions/runs/38467890246).
- **Result**: Clean green pass across all 4 scenarios in 4m49s (`live` job 115539249781)!
- Skill tool loaded `frontend-design:frontend-design` (9.5s).
- PreToolUse hook blocked `echo FORBIDDEN` (14.9s).
- SessionStart context quoted haiku (216.3s).
- MCP probe tool `probe_echo` called (27.2s).
3. **Configuration Updated**:
- Repository variable `E2E_LIVE_MODEL` updated to `opencode/muse-spark-1.3-contributor-free`.
+23 -7
View File
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
import { execFileSync } from "node:child_process"
import { copyFileSync, existsSync } from "node:fs"
import path from "node:path"
import { REPO } from "../lib/repo.ts"
import { Sandbox, type RunEvent } from "../lib/sandbox.ts"
@@ -55,20 +56,35 @@ describe.skipIf(Object.keys(keys).length === 0)("live model", () => {
await sandbox.c4o("install", "frontend-design@claude-plugins-official")
}, 300_000)
afterAll(() => sandbox?.dispose())
afterAll(async () => {
const reportDir = path.join(REPO, ".e2e", "report")
if (sandbox && existsSync(reportDir) && existsSync(sandbox.logFile)) {
copyFileSync(sandbox.logFile, path.join(reportDir, "c4o-live.log"))
}
await sandbox?.dispose()
})
/** Runs the prompt until `accept` is satisfied by the event stream, up to ATTEMPTS times. */
async function attempt(prompt: string, accept: (events: RunEvent[]) => boolean): Promise<RunEvent[]> {
let events: RunEvent[] = []
for (let index = 0; index < ATTEMPTS; index++) {
events = (await sandbox.run(prompt, { timeoutMs: 240_000 })).events
const result = await sandbox.run(prompt, { timeoutMs: 240_000 })
events = result.events
if (accept(events)) return events
const toolsUsed =
tools(events)
.map((item) => item.tool)
.join(", ") || "none"
const textSnippet = text(events).replace(/\s+/g, " ").trim().slice(0, 200)
const err = result.stderr ? `; stderr: ${result.stderr.trim().slice(0, 300)}` : ""
const errEvent = events.find((e) => e.type === "error" || (e.error !== undefined && e.error !== null))
const errDetail = errEvent ? `; error: ${JSON.stringify(errEvent)}` : ""
const eventSummary =
events.length === 0 ? "; events: none" : `; events(${events.length}): ${events.map((e) => e.type).join(",")}`
console.log(
`attempt ${index + 1} did not satisfy the scenario; tools used: ${
tools(events)
.map((item) => item.tool)
.join(", ") || "none"
}`,
`attempt ${index + 1} did not satisfy the scenario; code: ${result.code}; tools: ${toolsUsed}${
textSnippet ? `; text: "${textSnippet}"` : ""
}${err}${eventSummary}${errDetail}`,
)
}
return events
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@heretek-ai/claude-4-opencode",
"version": "0.1.2",
"version": "0.1.3",
"description": "Run Claude Code plugins and marketplaces on opencode v2",
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -104,7 +104,7 @@ export async function bindHooks(
}
}
}
return toClaudeToolCall(tool, input)
return toClaudeToolCall(tool, input, options.projectDir)
}
const registrations = [
+1 -1
View File
@@ -24,7 +24,7 @@ export function transcriptLines(messages: readonly unknown[], sessionID: string,
if (part.type === "text") line("assistant", [{ type: "text", text: String(part.text ?? "") }])
if (part.type !== "tool") continue
const state = isRecord(part.state) ? part.state : {}
const call = toClaudeToolCall(String(part.name), state.input)
const call = toClaudeToolCall(String(part.name), state.input, cwd)
line("assistant", [{ type: "tool_use", id: part.id, name: call.name, input: call.input }])
if (state.status !== "completed" && state.status !== "error") continue
const result = state.status === "completed" ? (state.output ?? state.content ?? "") : (state.error ?? "")
+2
View File
@@ -539,6 +539,7 @@ function addAgent(file: string, segments: string[], context: Context, agents: Ma
const model = asString(markdown.data.model)
const tools = asStringList(markdown.data.tools)
const disallowedTools = asStringList(markdown.data.disallowedTools)
const mcpAllow = asStringList(markdown.data.mcpAllow)
const color = asString(markdown.data.color)
agents.set(id, {
id,
@@ -546,6 +547,7 @@ function addAgent(file: string, segments: string[], context: Context, agents: Ma
...(model === undefined ? {} : { model }),
...(tools === undefined ? {} : { tools }),
...(disallowedTools === undefined ? {} : { disallowedTools }),
...(mcpAllow === undefined ? {} : { mcpAllow }),
...(color === undefined ? {} : { color }),
body: markdown.body,
path: file,
+2
View File
@@ -37,6 +37,8 @@ export interface AgentDef {
model?: string
tools?: string[]
disallowedTools?: string[]
/** Opt-in MCP grants: `<server>:<tool>` or `<server>:*`. Fail-closed; `*` never implies MCP. */
mcpAllow?: string[]
color?: string
body: string
path: string
+6 -4
View File
@@ -41,9 +41,11 @@ export interface OpencodeAgent {
* Claude model aliases (`sonnet`, `opus`, `inherit`) have no provider-neutral meaning in opencode,
* so translated agents inherit the session's model.
*
* Drift G: `permissions` only covers built-in tools. MCP/plugin tool names (`mcp__*`) map to
* nothing and grant nothing (fail-closed). Mapping them to `shell`/`read` would over-privilege
* read-only agents, so an allowlist mentioning MCP tools stays deny-all until an opt-in exists.
* Drift G: `permissions` covers built-in tools via `tools`/`disallowedTools`.
* MCP/plugin tool names (`mcp__*`) in those lists still grant nothing by themselves
* (fail-closed); MCP access is opt-in only via the per-server `mcpAllow`
* frontmatter (`<server>:<tool>` / `<server>:*`), translated to `mcp:call`
* allows with trailing denies winning. `*` never implies MCP.
*/
export function toAgent(agent: AgentDef, context: VarContext): OpencodeAgent {
return {
@@ -52,7 +54,7 @@ export function toAgent(agent: AgentDef, context: VarContext): OpencodeAgent {
system: substitute(agent.body, context).trim(),
mode: "subagent",
...(agent.color === undefined ? {} : { color: agent.color }),
permissions: agentPermissions(agent.tools, agent.disallowedTools),
permissions: agentPermissions(agent.tools, agent.disallowedTools, agent.mcpAllow),
}
}
+204 -9
View File
@@ -2,12 +2,15 @@
* Claude Code and opencode name their built-in tools and tool inputs differently.
* Hooks and agent tool lists are written in Claude's vocabulary, so both directions are needed.
*/
import path from "node:path"
import { realpathSync, statSync } from "node:fs"
/**
* opencode permission action for each Claude tool an agent may list in `tools`.
* Tools absent here grant nothing: `BashOutput` and `KillShell` only manage existing shells,
* and mapping them to `shell` would let a read-only agent run commands.
* Drift G: MCP/plugin tools (`mcp__*`) are also absent by design (fail-closed, see `toAgent`).
* Drift G: MCP/plugin tools (`mcp__*`) grant nothing unless explicitly listed in `mcpAllow`
* (fail-closed opt-in, see `toAgent`).
*/
const ACTIONS: Record<string, string[]> = {
Read: ["read"],
@@ -40,18 +43,171 @@ export interface PermissionRule {
effect: "allow" | "deny" | "ask"
}
const MCP_ACTION = "mcp:call"
const SERVER_RE = /^[A-Za-z0-9_.-]+$/
const TOOL_RE = /^[A-Za-z0-9_.-]+$/
interface McpTriple {
server: string
tool: string
resource: string
}
/**
* Turns an agent's `tools` allowlist and `disallowedTools` denylist into opencode permission rules.
* opencode applies the last matching rule, so the deny-all comes first.
* Parses an `mcpAllow` entry (`<server>:<tool>` or `<server>:*`).
* Strict and fail-closed: no suffix-stripping — `filesystem:read_file(extra)`
* is rejected, not stripped to an over-grant. Bare `*`, `<tool>` without a
* server, `*:tool`, empty parts, and `$`/backtick content are rejected.
*/
export function agentPermissions(tools: string[] | undefined, disallowed: string[] | undefined): PermissionRule[] {
function parseMcpAllowEntry(entry: unknown): McpTriple | undefined {
if (typeof entry !== "string") return undefined
const trimmed = entry.trim()
if (trimmed === "" || trimmed === "*") return undefined
if (trimmed.includes("(") || trimmed.includes(")")) return undefined
const parts = trimmed.split(":")
if (parts.length !== 2) return undefined
const server = parts[0]!.trim()
const tool = parts[1]!.trim()
if (server === "" || server === "*" || !SERVER_RE.test(server)) return undefined
if (tool === "") return undefined
if (tool !== "*" && !TOOL_RE.test(tool)) return undefined
return { server, tool, resource: `${server}:${tool}` }
}
/**
* Parses an MCP deny spelling from `disallowedTools` into a canonical triple.
* Accepts the canonical `server:tool` form and the `mcp__*` hook forms:
* `mcp__<server>__<tool>` and `mcp__plugin_<plugin>_<server>__<tool>`.
* For denies (fail-closed to more deny) a trailing `(...)` suffix is stripped
* before parsing; allows never strip.
*/
function parseMcpHookDeny(entry: string): McpTriple | undefined {
const stripped = toolName(entry)
if (stripped === "" || stripped === "*") return undefined
if (!stripped.startsWith("mcp__")) return undefined
const rest = stripped.slice("mcp__".length)
if (rest === "" || rest.includes("(") || rest.includes(")")) return undefined
const halves = rest.split("__")
if (halves.length !== 2) return undefined
const head = halves[0]!.trim()
const tool = halves[1]!.trim()
if (head === "" || tool === "" || tool === "*:*") return undefined
if (tool !== "*" && !TOOL_RE.test(tool)) return undefined
// Head is `<server>` or `<plugin>_<server>` (encoded parts never contain a
// bare `_`, so the last `_` segment is the server).
const headSegments = head.split("_").filter((segment) => segment !== "")
const server = headSegments[headSegments.length - 1] ?? ""
if (server === "" || server === "*" || !SERVER_RE.test(server)) return undefined
if (server === "plugin" && headSegments.length <= 1) return undefined
return { server, tool, resource: `${server}:${tool}` }
}
function parseMcpDisallowEntry(entry: unknown): McpTriple | undefined {
if (typeof entry !== "string") return undefined
const trimmed = entry.trim()
if (trimmed === "") return undefined
// Canonical form (strip suffix for denies: more deny is fail-closed).
const canonical = parseMcpAllowEntry(toolName(trimmed))
if (canonical) return canonical
return parseMcpHookDeny(trimmed)
}
/**
* Turns an agent's `tools` allowlist, `disallowedTools` denylist, and optional
* `mcpAllow` opt-in into opencode permission rules.
* opencode applies the last matching rule, so the deny-all comes first,
* built-in allows next, MCP allows after them, and all denies trail.
*
* Fail-closed defaults:
* - No `mcpAllow` → zero `mcp:call` rules.
* - `tools: ["*"]` → no MCP rules at all (`*` never implies MCP).
* - Unscoped `mcpAllow` (`*`, `read_file`, `*:tool`) → rejected, no grant.
* - Trailing MCP denies win over MCP allows.
*/
export function agentPermissions(
tools: string[] | undefined,
disallowed: string[] | undefined,
mcpAllow?: string[] | undefined,
): PermissionRule[] {
const rules: PermissionRule[] = []
const actions = (names: string[]) => [...new Set(names.flatMap((name) => ACTIONS[toolName(name)] ?? []))]
if (tools !== undefined && !tools.includes("*")) {
// `*` is a passthrough: no deny-all, no rules from the allowlist side.
// It never implies MCP, so skip all MCP processing.
if (tools !== undefined && tools.includes("*")) {
for (const action of actions(disallowed ?? [])) rules.push({ action, resource: "*", effect: "deny" })
return rules
}
const allows: McpTriple[] = []
const seenAllow = new Set<string>()
for (const entry of mcpAllow ?? []) {
const parsed = parseMcpAllowEntry(entry)
if (!parsed || seenAllow.has(parsed.resource)) continue
seenAllow.add(parsed.resource)
allows.push(parsed)
}
const needsDenyAll = (tools !== undefined || allows.length > 0) && true
if (tools !== undefined) {
rules.push({ action: "*", resource: "*", effect: "deny" })
for (const action of actions(tools)) rules.push({ action, resource: "*", effect: "allow" })
} else if (allows.length > 0) {
rules.push({ action: "*", resource: "*", effect: "deny" })
}
// One allow per spelling (Code-Mode inner `<server>_<tool>` and hook
// `mcp__plugin_*`), sharing the same `server:tool` resource triple.
for (const allow of allows) {
rules.push({ action: MCP_ACTION, resource: allow.resource, effect: "allow" })
rules.push({ action: MCP_ACTION, resource: allow.resource, effect: "allow" })
}
for (const action of actions(disallowed ?? [])) rules.push({ action, resource: "*", effect: "deny" })
// Trailing MCP denies win, including flips of an allowed triple and
// standalone denies with no corresponding allow.
const denyResources = new Set<string>()
const denyOrder: string[] = []
const considerDeny = (resource: string) => {
if (denyResources.has(resource)) return
denyResources.add(resource)
denyOrder.push(resource)
}
const allowByInner = new Map<string, McpTriple>()
for (const allow of allows) {
allowByInner.set(`${allow.server}_${allow.tool}`, allow)
// Wildcard allows also match specific inner denies on that server.
if (allow.tool === "*") allowByInner.set(`${allow.server}_*`, allow)
}
for (const entry of disallowed ?? []) {
if (typeof entry !== "string") continue
const parsed = parseMcpDisallowEntry(entry)
if (parsed) {
considerDeny(parsed.resource)
continue
}
// Code-Mode inner `<server>_<tool>`: only matched against known allows
// to avoid ambiguous server/tool splits granting or denying blindly.
const inner = toolName(entry).trim()
if (inner !== "") {
const direct = allowByInner.get(inner)
if (direct) {
// For a wildcard allow, deny the specific triple, not the wildcard.
if (direct.tool === "*" && inner !== `${direct.server}_*`) {
const toolPart = inner.slice(direct.server.length + 1)
if (toolPart !== "" && TOOL_RE.test(toolPart)) considerDeny(`${direct.server}:${toolPart}`)
continue
}
considerDeny(direct.resource)
} else {
// Inner spelling of a server with a wildcard allow: deny specifically.
const underscore = inner.indexOf("_")
if (underscore > 0) {
const maybeServer = inner.slice(0, underscore)
const maybeTool = inner.slice(underscore + 1)
const wildcard = allows.find((allow) => allow.tool === "*" && allow.server === maybeServer)
if (wildcard && maybeTool !== "" && TOOL_RE.test(maybeTool)) considerDeny(`${maybeServer}:${maybeTool}`)
}
}
}
}
for (const resource of denyOrder) rules.push({ action: MCP_ACTION, resource, effect: "deny" })
void needsDenyAll
return rules
}
@@ -73,7 +229,8 @@ const FIELDS: Record<string, { claude: string; fields: Record<string, string> }>
// Drift A: shell `workdir` has no Claude counterpart and is intentionally unmapped. `toClaudeToolCall`
// drops it (pinned by test) so matchers never see it, while `applyUpdatedInput` preserves it via
// spread so hooks cannot clobber it. Forwarding it as `working_directory` would require a reverse
// map plus fixture updates and is out of scope.
// map plus fixture updates and is out of scope. WS5 adds a separate validated `cwd` field
// (structured `{cwd: string}`, never parsed from command text) that is surfaced when valid.
shell: { claude: "Bash", fields: { command: "command", timeout: "timeout", background: "run_in_background" } },
read: { claude: "Read", fields: { path: "file_path", offset: "offset", limit: "limit" } },
write: { claude: "Write", fields: { path: "file_path", content: "content" } },
@@ -90,18 +247,56 @@ const FIELDS: Record<string, { claude: string; fields: Record<string, string> }>
question: { claude: "AskUserQuestion", fields: { questions: "questions" } },
}
/**
* Validates a structured shell `cwd` against `projectDir` (WS5).
* Returns the real absolute path when valid, else `undefined` (caller uses
* `projectDir`). Fail-closed: type gate → no `$`/backtick expansion →
* `path.resolve` + `realpath` → containment under `projectDir` (real path +
* sep) → must exist and be a directory. Never parses `cd <dir> && ...`
* out of the command text.
*/
export function validateShellCwd(cwd: unknown, projectDir: string): string | undefined {
if (typeof cwd !== "string" || cwd.length === 0 || cwd.trim() === "") return undefined
if (cwd.includes("\0")) return undefined
if (/[$`]/.test(cwd)) return undefined
try {
const resolved = path.resolve(projectDir, cwd)
const realProject = realpathSync(projectDir)
const realCwd = realpathSync(resolved)
if (realCwd !== realProject && !realCwd.startsWith(realProject + path.sep)) return undefined
if (!statSync(realCwd).isDirectory()) return undefined
return realCwd
} catch {
return undefined
}
}
/**
* Describes an opencode tool call the way a Claude Code hook expects to see it.
* Tools with no Claude counterpart keep their own name and input, so matchers can still target them.
*
* WS5: when `projectDir` is provided and the call is `shell` with a structured
* `cwd`, the validated absolute cwd is included as `cwd` in the Bash input.
* Invalid cwd is ignored (hooks see `projectDir` via the session). `workdir`
* is still dropped, and `cd <dir> && ...` prefixes gain no cwd semantics.
*/
export function toClaudeToolCall(tool: string, input: unknown): ClaudeToolCall {
export function toClaudeToolCall(tool: string, input: unknown, projectDir?: string): ClaudeToolCall {
const source: Input = typeof input === "object" && input !== null ? (input as Input) : {}
const mapping = FIELDS[tool]
if (!mapping) return { name: tool, input: source }
return { name: mapping.claude, input: pick(source, mapping.fields) }
const base = pick(source, mapping.fields)
if (tool === "shell" && projectDir !== undefined) {
const validated = validateShellCwd(source.cwd, projectDir)
if (validated !== undefined) return { name: mapping.claude, input: { ...base, cwd: validated } }
}
return { name: mapping.claude, input: base }
}
/** Applies a hook's `updatedInput`, written in Claude's field names, back onto the opencode input. */
/**
* Applies a hook's `updatedInput`, written in Claude's field names, back onto the opencode input.
* WS5: the original `cwd`/`workdir` are preserved via spread; hooks cannot set
* cwd through `updatedInput` (no reverse map for it — fail-closed).
*/
export function applyUpdatedInput(tool: string, original: unknown, updated: Record<string, unknown>): unknown {
const mapping = FIELDS[tool]
const source: Input = typeof original === "object" && original !== null ? (original as Input) : {}
+192 -1
View File
@@ -1,5 +1,8 @@
import { expect, test } from "bun:test"
import { agentPermissions, applyUpdatedInput, toClaudeToolCall } from "../src/translate/tools.ts"
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { agentPermissions, applyUpdatedInput, toClaudeToolCall, validateShellCwd } from "../src/translate/tools.ts"
test("a tools allowlist becomes deny-all followed by specific allows", () => {
expect(agentPermissions(["Glob", "Grep", "Read", "WebFetch"], undefined)).toEqual([
@@ -61,3 +64,191 @@ test("a hook's updatedInput maps back onto opencode field names", () => {
content: "b",
})
})
// WS4 validation matrix: MCP permission opt-in (fail-closed, per-server mcpAllow).
test("WS4: no mcpAllow means zero mcp:call rules", () => {
const withTools = agentPermissions(["Read"], undefined, undefined)
expect(withTools).toContainEqual({ action: "*", resource: "*", effect: "deny" })
expect(withTools.filter((rule) => rule.action === "mcp:call")).toEqual([])
expect(agentPermissions(undefined, undefined, []).filter((rule) => rule.action === "mcp:call")).toEqual([])
})
test("WS4: tools star never implies MCP", () => {
expect(agentPermissions(["*"], undefined, ["filesystem:read_file"])).toEqual([])
expect(
agentPermissions(["*"], ["Write"], ["filesystem:read_file"]).filter((rule) => rule.action === "mcp:call"),
).toEqual([])
})
test("WS4: exact mcpAllow emits both-spelling allows", () => {
const rules = agentPermissions(undefined, undefined, ["filesystem:read_file"])
expect(rules[0]).toEqual({ action: "*", resource: "*", effect: "deny" })
const allows = rules.filter((rule) => rule.action === "mcp:call" && rule.effect === "allow")
expect(allows).toEqual([
{ action: "mcp:call", resource: "filesystem:read_file", effect: "allow" },
{ action: "mcp:call", resource: "filesystem:read_file", effect: "allow" },
])
expect(rules.filter((rule) => rule.effect === "deny")).toEqual([{ action: "*", resource: "*", effect: "deny" }])
})
test("WS4: trailing MCP denies win over allows", () => {
for (const deny of [
"filesystem:read_file",
"mcp__filesystem__read_file",
"mcp__plugin_myplug_filesystem__read_file",
"filesystem_read_file",
]) {
const rules = agentPermissions(undefined, [deny], ["filesystem:read_file"])
const allows = rules.filter((rule) => rule.action === "mcp:call" && rule.effect === "allow")
const denies = rules.filter((rule) => rule.action === "mcp:call" && rule.effect === "deny")
expect(allows.length).toBe(2)
expect(denies).toEqual([{ action: "mcp:call", resource: "filesystem:read_file", effect: "deny" }])
const lastAllow = rules.lastIndexOf(allows[allows.length - 1]!)
const lastDeny = rules.lastIndexOf(denies[denies.length - 1]!)
expect(lastDeny).toBeGreaterThan(lastAllow)
}
})
test("WS4: unscoped mcpAllow is rejected with no grant", () => {
for (const scoped of [["*"], ["read_file"], ["*:read_file"], ["*:*"], [""]]) {
const rules = agentPermissions(undefined, undefined, scoped)
expect(rules.filter((rule) => rule.action === "mcp:call")).toEqual([])
}
})
test("WS4: suffixed mcpAllow is rejected, not stripped to an over-grant", () => {
const rules = agentPermissions(undefined, undefined, ["filesystem:read_file(extra)"])
expect(rules.filter((rule) => rule.action === "mcp:call")).toEqual([])
})
test("WS4: MCP names in tools/disallowedTools alone grant nothing", () => {
const rules = agentPermissions(["mcp__filesystem__read_file"], undefined, undefined)
expect(rules).toEqual([{ action: "*", resource: "*", effect: "deny" }])
})
test("WS4: wildcard server-tool allow and ordering with built-ins", () => {
const rules = agentPermissions(["Read"], ["Write"], ["filesystem:*"])
expect(rules[0]).toEqual({ action: "*", resource: "*", effect: "deny" })
expect(rules).toContainEqual({ action: "read", resource: "*", effect: "allow" })
expect(rules.filter((rule) => rule.action === "mcp:call" && rule.effect === "allow")).toEqual([
{ action: "mcp:call", resource: "filesystem:*", effect: "allow" },
{ action: "mcp:call", resource: "filesystem:*", effect: "allow" },
])
const mcpAllowLast = rules
.map((rule, index) => ({ rule, index }))
.filter(({ rule }) => rule.action === "mcp:call" && rule.effect === "allow")
.at(-1)!.index
const editDeny = rules
.map((rule, index) => ({ rule, index }))
.filter(({ rule }) => rule.action === "edit" && rule.effect === "deny")
.at(-1)!.index
expect(mcpAllowLast).toBeLessThan(editDeny)
})
// WS5 validation matrix: structured cwd (fail-closed to projectDir).
function makeProject() {
const root = mkdtempSync(path.join(tmpdir(), "c4o-ws5-"))
const sub = path.join(root, "sub")
mkdirSync(sub)
const file = path.join(root, "file.txt")
writeFileSync(file, "hi")
return { root, sub, file }
}
test("WS5: non-string and empty cwd are ignored", () => {
const { root } = makeProject()
try {
expect(validateShellCwd(undefined, root)).toBeUndefined()
expect(validateShellCwd(42, root)).toBeUndefined()
expect(validateShellCwd("", root)).toBeUndefined()
expect(validateShellCwd(" ", root)).toBeUndefined()
expect(toClaudeToolCall("shell", { command: "ls" }, root)).toEqual({ name: "Bash", input: { command: "ls" } })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: expansion syntax in cwd is ignored with no expansion", () => {
const { root, sub } = makeProject()
try {
for (const cwd of ["${HOME}", "$HOME", "$(whoami)", "`whoami`", "sub/$(whoami)", "sub/${USER}"]) {
expect(validateShellCwd(cwd, root)).toBeUndefined()
}
expect(validateShellCwd("sub", root)).toBe(path.resolve(root, "sub"))
void sub
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: outside-projectDir cwd is ignored", () => {
const { root } = makeProject()
try {
expect(validateShellCwd("/etc", root)).toBeUndefined()
expect(validateShellCwd("..", root)).toBeUndefined()
expect(validateShellCwd(path.join(root, "..", "etc"), root)).toBeUndefined()
expect(toClaudeToolCall("shell", { command: "ls", cwd: "/etc" }, root)).toEqual({
name: "Bash",
input: { command: "ls" },
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: symlink escape is ignored", () => {
const { root } = makeProject()
const link = path.join(root, "escape")
try {
symlinkSync("/etc", link)
expect(validateShellCwd("escape", root)).toBeUndefined()
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: non-existent and file-not-directory cwd are ignored", () => {
const { root } = makeProject()
try {
expect(validateShellCwd("nope", root)).toBeUndefined()
expect(validateShellCwd("file.txt", root)).toBeUndefined()
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: valid subdir cwd is forwarded, cd-prefix gains no semantics", () => {
const { root, sub } = makeProject()
try {
const validated = validateShellCwd("sub", root)!
expect(validated).toBe(path.resolve(root, "sub"))
void sub
expect(toClaudeToolCall("shell", { command: "ls", cwd: "sub" }, root)).toEqual({
name: "Bash",
input: { command: "ls", cwd: validated },
})
// `cd` prefix is never parsed as cwd.
expect(toClaudeToolCall("shell", { command: "cd sub && ls" }, root)).toEqual({
name: "Bash",
input: { command: "cd sub && ls" },
})
// Legacy workdir stays dropped while validated cwd is surfaced.
expect(toClaudeToolCall("shell", { command: "ls", workdir: "/x", cwd: "sub" }, root)).toEqual({
name: "Bash",
input: { command: "ls", cwd: validated },
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: hooks cannot set cwd through updatedInput", () => {
expect(
applyUpdatedInput("shell", { command: "ls", cwd: "/proj/sub", workdir: "/x" }, { command: "echo safe" }),
).toEqual({ command: "echo safe", cwd: "/proj/sub", workdir: "/x" })
expect(
applyUpdatedInput("shell", { command: "ls", cwd: "/proj/sub" }, { command: "echo safe", cwd: "/evil" }),
).toEqual({ command: "echo safe", cwd: "/proj/sub" })
})