Skip to main content

tuff_adapter_opencode/
lib.rs

1use std::path::Path;
2
3use tuff_hooks_spec::{
4    CompatibilityEntry, CompatibilityMatrix, CoverageLevel, HookEvent, SPEC_VERSION,
5};
6
7use tuff_core::adapter::{AgentAdapter, HookSettingsShape};
8use tuff_core::error::Result;
9use tuff_core::manifest::{CapabilityType, McpServerConfig, McpTransport};
10use tuff_core::policy::{
11    PolicyCoverageEntry, PolicyEffect, PolicyRule, PolicySubject, PolicySubjectKind,
12};
13
14pub const ID: &str = "opencode";
15pub const DISPLAY_NAME: &str = "OpenCode";
16
17/// Policies and MCP servers, both of which OpenCode reads from
18/// `opencode.json`. Skills reach OpenCode through the `open-agents` layout,
19/// and OpenCode hooks are plugins, which this adapter does not manage.
20pub const SUPPORTED_TYPES: &[CapabilityType] = &[CapabilityType::Policy, CapabilityType::McpServer];
21
22pub const SUPPORTED_AGENTS: &[&str] = &["OpenCode"];
23
24/// The OpenCode config file policy rules are compiled into. OpenCode loads
25/// `.opencode/opencode.json` after the project's `opencode.json` and applies
26/// the last permission rule that matches, so rules here take precedence over
27/// the project's own.
28pub const CONFIG_RELPATH: &str = ".opencode/opencode.json";
29const OPENCODE_PERMISSIONS_DOCS: &str = "https://opencode.ai/docs/permissions/";
30
31pub struct OpenCode;
32
33const fn unsupported_hook(event: HookEvent) -> CompatibilityEntry {
34    CompatibilityEntry {
35        event,
36        native_event: None,
37        aliases: &[],
38        coverage: CoverageLevel::Unsupported,
39        scope: &[],
40        caveat: Some("Tuff does not manage OpenCode hooks, which OpenCode loads as plugins."),
41        source: None,
42        since_harness_version: None,
43        until_harness_version: None,
44    }
45}
46
47/// Every event is unsupported: this adapter takes no hooks, and
48/// `tuff hooks spec` lists only adapters that do.
49pub const HOOK_COMPATIBILITY: CompatibilityMatrix = CompatibilityMatrix {
50    spec_version: SPEC_VERSION,
51    adapter: ID,
52    events: &[
53        unsupported_hook(HookEvent::SessionStart),
54        unsupported_hook(HookEvent::SessionEnd),
55        unsupported_hook(HookEvent::PreToolUse),
56        unsupported_hook(HookEvent::PostToolUse),
57        unsupported_hook(HookEvent::BeforeFinish),
58        unsupported_hook(HookEvent::AfterSave),
59        unsupported_hook(HookEvent::Stop),
60    ],
61};
62
63/// How OpenCode enforces each kind of policy rule, from its permissions
64/// documentation and source, checked against OpenCode 1.18.15 on 2026-09-15.
65pub fn policy_matrix() -> Vec<PolicyCoverageEntry> {
66    const PRECEDENCE: &str = "OpenCode loads .opencode/opencode.json after the project's opencode.json, but inline OPENCODE_CONFIG_CONTENT, managed config, and an agent's own permission settings are applied after it";
67    const COMMAND: &str = "matches each command OpenCode parses from the shell input, including one with a redirection; the same program run through sh -c, by absolute path, or with options before the subcommand is not matched";
68    const READ: &str = "covers OpenCode's read tool; grep, glob, list, and shell commands are separate permissions and are not covered";
69    const EDIT: &str = "covers OpenCode's edit, write, and patch tools; shell commands that write files are not covered";
70    const MCP_DENY: &str = "a denied tool is hidden from the agent; OpenCode names a tool <server>_<tool>, with characters other than letters, digits, _ and - replaced by _";
71    const MCP_ASK: &str = "OpenCode names a tool <server>_<tool>, with characters other than letters, digits, _ and - replaced by _";
72    const ASK: &str = "opencode run rejects the request, and opencode --auto approves it";
73    let row = |effect, subject, coverage, mechanism: &str, caveat: String| PolicyCoverageEntry {
74        effect,
75        subject,
76        coverage,
77        mechanism: Some(mechanism.to_string()),
78        caveat: Some(caveat),
79        source: Some(OPENCODE_PERMISSIONS_DOCS.to_string()),
80    };
81    use CoverageLevel::{Full, Partial};
82    use PolicyEffect::{Ask, Deny};
83    use PolicySubjectKind::{Command, Edit, Mcp, Read};
84    vec![
85        row(
86            Deny,
87            Command,
88            Partial,
89            ".opencode/opencode.json permission.bash \"<command> *\": \"deny\"",
90            format!("{COMMAND}; {PRECEDENCE}"),
91        ),
92        row(
93            Deny,
94            Read,
95            Partial,
96            ".opencode/opencode.json permission.read \"<path>\": \"deny\"",
97            format!("{READ}; {PRECEDENCE}"),
98        ),
99        row(
100            Deny,
101            Edit,
102            Partial,
103            ".opencode/opencode.json permission.edit \"<path>\": \"deny\"",
104            format!("{EDIT}; {PRECEDENCE}"),
105        ),
106        row(
107            Deny,
108            Mcp,
109            Full,
110            ".opencode/opencode.json permission \"<server>_<tool>\": \"deny\"",
111            format!("{MCP_DENY}; {PRECEDENCE}"),
112        ),
113        row(
114            Ask,
115            Command,
116            Partial,
117            ".opencode/opencode.json permission.bash \"<command> *\": \"ask\"",
118            format!("{COMMAND}; {ASK}; {PRECEDENCE}"),
119        ),
120        row(
121            Ask,
122            Read,
123            Partial,
124            ".opencode/opencode.json permission.read \"<path>\": \"ask\"",
125            format!("{READ}; {ASK}; {PRECEDENCE}"),
126        ),
127        row(
128            Ask,
129            Edit,
130            Partial,
131            ".opencode/opencode.json permission.edit \"<path>\": \"ask\"",
132            format!("{EDIT}; {ASK}; {PRECEDENCE}"),
133        ),
134        row(
135            Ask,
136            Mcp,
137            Full,
138            ".opencode/opencode.json permission \"<server>_<tool>\": \"ask\"",
139            format!("{MCP_ASK}; {ASK}; {PRECEDENCE}"),
140        ),
141    ]
142}
143
144/// A policy path pattern, read as `.gitignore` reads a pattern at the
145/// project root, as OpenCode path patterns.
146///
147/// OpenCode matches a read or edit against the path relative to the
148/// project, and its `*` also matches `/`. A pattern with a `/` at its start
149/// or middle stays anchored, so `secrets/**` is kept. One without matches at
150/// any depth, which takes two patterns: `.env` and `*/.env`. A trailing `/`
151/// names a directory's contents.
152pub fn permission_paths(pattern: &str) -> Vec<String> {
153    let pattern = pattern.trim_start_matches("./");
154    let anchored = pattern.trim_end_matches('/').contains('/');
155    let normalized = if pattern.ends_with('/') {
156        format!("{pattern}*")
157    } else {
158        pattern.to_string()
159    };
160    if anchored {
161        vec![normalized]
162    } else {
163        vec![normalized.clone(), format!("*/{normalized}")]
164    }
165}
166
167/// A server or tool name as OpenCode writes it into a tool's permission
168/// name: characters other than letters, digits, `_`, and `-` become `_`. A
169/// policy's `*` is kept, since OpenCode matches permission names as
170/// patterns.
171fn tool_name_part(name: &str) -> String {
172    name.chars()
173        .map(|character| {
174            if character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '*') {
175                character
176            } else {
177                '_'
178            }
179        })
180        .collect()
181}
182
183/// The OpenCode rules one policy rule compiles to, as Tuff records them: a
184/// permission name, then a space and a pattern when the rule sits in that
185/// permission's object. The effect is the action the rule is written with.
186pub fn permission_rules(rule: &PolicyRule) -> Result<Vec<String>> {
187    Ok(match rule.subject()? {
188        PolicySubject::Command(arguments) => vec![format!("bash {} *", arguments.join(" "))],
189        PolicySubject::Read(patterns) => patterns
190            .iter()
191            .flat_map(|pattern| permission_paths(pattern))
192            .map(|pattern| format!("read {pattern}"))
193            .collect(),
194        PolicySubject::Edit(patterns) => patterns
195            .iter()
196            .flat_map(|pattern| permission_paths(pattern))
197            .map(|pattern| format!("edit {pattern}"))
198            .collect(),
199        PolicySubject::Mcp { server, tool } => {
200            vec![format!(
201                "{}_{}",
202                tool_name_part(server),
203                tool_name_part(tool)
204            )]
205        }
206    })
207}
208
209impl AgentAdapter for OpenCode {
210    fn id(&self) -> &'static str {
211        ID
212    }
213
214    fn display_name(&self) -> &'static str {
215        DISPLAY_NAME
216    }
217
218    fn dir_prefix(&self) -> &'static str {
219        ".opencode"
220    }
221
222    /// MCP servers join the policy rules in `.opencode/opencode.json`, which
223    /// OpenCode merges over the project's `opencode.json`, so the project's
224    /// own file is never edited.
225    fn mcp_config_relpath(&self) -> &'static str {
226        CONFIG_RELPATH
227    }
228
229    /// OpenCode expands `{env:VAR}` in its config.
230    fn mcp_env_reference(&self, var: &str) -> String {
231        format!("{{env:{var}}}")
232    }
233
234    /// OpenCode's `mcp.<id>` entry: `type` is `local` or `remote`, a local
235    /// server's program and arguments are one `command` array, and its
236    /// variables sit under `environment`.
237    fn mcp_server_entry(&self, server: &McpServerConfig) -> serde_json::Value {
238        match server.transport {
239            McpTransport::Stdio => {
240                let mut command = vec![server.command.clone().unwrap_or_default()];
241                command.extend(server.args.iter().cloned());
242                let mut entry = serde_json::json!({"type": "local", "command": command});
243                if !server.env.is_empty() {
244                    let environment: serde_json::Map<String, serde_json::Value> = server
245                        .env
246                        .iter()
247                        .map(|(name, reference)| {
248                            (
249                                name.clone(),
250                                serde_json::Value::String(
251                                    self.mcp_env_reference(&reference.from_env),
252                                ),
253                            )
254                        })
255                        .collect();
256                    entry["environment"] = serde_json::Value::Object(environment);
257                }
258                entry
259            }
260            McpTransport::Http => {
261                let mut entry = serde_json::json!({
262                    "type": "remote",
263                    "url": server.url.clone().unwrap_or_default(),
264                });
265                if !server.headers.is_empty() {
266                    let headers: serde_json::Map<String, serde_json::Value> = server
267                        .headers
268                        .iter()
269                        .map(|(name, reference)| {
270                            let value =
271                                reference.render(&self.mcp_env_reference(&reference.from_env));
272                            (name.clone(), serde_json::Value::String(value))
273                        })
274                        .collect();
275                    entry["headers"] = serde_json::Value::Object(headers);
276                }
277                entry
278            }
279        }
280    }
281
282    fn supported_agents(&self) -> &[&'static str] {
283        SUPPORTED_AGENTS
284    }
285
286    fn kinds_supported(&self) -> &[CapabilityType] {
287        SUPPORTED_TYPES
288    }
289
290    fn hook_compatibility(&self) -> &'static CompatibilityMatrix {
291        &HOOK_COMPATIBILITY
292    }
293
294    fn hook_settings_relpath(&self) -> &'static str {
295        CONFIG_RELPATH
296    }
297
298    fn scaffold_hook_event(&self) -> &'static str {
299        ""
300    }
301
302    fn hook_settings_shape(&self) -> HookSettingsShape {
303        HookSettingsShape::Flat
304    }
305
306    fn policy_compatibility(&self) -> Vec<PolicyCoverageEntry> {
307        policy_matrix()
308    }
309
310    fn permissions_settings_relpath(&self) -> Option<&'static str> {
311        Some(CONFIG_RELPATH)
312    }
313
314    fn native_permission_rules(&self, rule: &PolicyRule) -> Result<Option<Vec<String>>> {
315        permission_rules(rule).map(Some)
316    }
317
318    fn detect(&self, repo_root: &Path) -> bool {
319        repo_root.join("opencode.json").exists()
320            || repo_root.join("opencode.jsonc").exists()
321            || repo_root.join(".opencode").exists()
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn rule(effect: &str) -> PolicyRule {
330        PolicyRule {
331            effect: effect.to_string(),
332            command: None,
333            read: None,
334            edit: None,
335            mcp: None,
336            reason: None,
337        }
338    }
339
340    fn words(words: &[&str]) -> Option<Vec<String>> {
341        Some(words.iter().map(|word| word.to_string()).collect())
342    }
343
344    #[test]
345    fn each_kind_of_rule_compiles_to_an_opencode_permission() {
346        let compiled = |rule: PolicyRule| permission_rules(&rule).unwrap();
347        assert_eq!(
348            compiled(PolicyRule {
349                command: words(&["git", "push", "--force"]),
350                ..rule("deny")
351            }),
352            ["bash git push --force *"]
353        );
354        assert_eq!(
355            compiled(PolicyRule {
356                read: words(&[".env", "secrets/**"]),
357                ..rule("deny")
358            }),
359            ["read .env", "read */.env", "read secrets/**"]
360        );
361        assert_eq!(
362            compiled(PolicyRule {
363                edit: words(&["infra/prod/", "certs/"]),
364                ..rule("ask")
365            }),
366            ["edit infra/prod/*", "edit certs/*", "edit */certs/*"]
367        );
368        assert_eq!(
369            compiled(PolicyRule {
370                mcp: Some("my.server:delete_*".to_string()),
371                ..rule("deny")
372            }),
373            ["my_server_delete_*"]
374        );
375    }
376
377    #[test]
378    fn the_policy_matrix_enforces_every_kind_of_rule() {
379        let matrix = OpenCode.policy_compatibility();
380        assert_eq!(matrix.len(), 8);
381        for entry in &matrix {
382            let expected = if entry.subject == PolicySubjectKind::Mcp {
383                CoverageLevel::Full
384            } else {
385                CoverageLevel::Partial
386            };
387            assert_eq!(entry.coverage, expected, "{entry:?}");
388            assert!(entry.caveat.is_some(), "{entry:?}");
389        }
390    }
391
392    #[test]
393    fn mcp_entries_take_opencodes_shape_and_env_syntax() {
394        use tuff_core::manifest::{EnvRef, HeaderRef, McpServerConfig, McpTransport};
395        let local = McpServerConfig {
396            transport: McpTransport::Stdio,
397            command: Some("npx".to_string()),
398            args: vec!["-y".to_string(), "pkg".to_string()],
399            url: None,
400            env: [(
401                "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
402                EnvRef {
403                    from_env: "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
404                },
405            )]
406            .into_iter()
407            .collect(),
408            headers: Default::default(),
409            metadata: None,
410        };
411        assert_eq!(
412            OpenCode.mcp_server_entry(&local),
413            serde_json::json!({
414                "type": "local",
415                "command": ["npx", "-y", "pkg"],
416                "environment": {"GITHUB_PERSONAL_ACCESS_TOKEN": "{env:GITHUB_PERSONAL_ACCESS_TOKEN}"},
417            })
418        );
419
420        let remote = McpServerConfig {
421            transport: McpTransport::Http,
422            command: None,
423            args: Vec::new(),
424            url: Some("https://mcp.example.test/mcp".to_string()),
425            env: Default::default(),
426            headers: [(
427                "Authorization".to_string(),
428                HeaderRef {
429                    from_env: "EXAMPLE_TOKEN".to_string(),
430                    format: Some("Bearer {}".to_string()),
431                },
432            )]
433            .into_iter()
434            .collect(),
435            metadata: None,
436        };
437        assert_eq!(
438            OpenCode.mcp_server_entry(&remote),
439            serde_json::json!({
440                "type": "remote",
441                "url": "https://mcp.example.test/mcp",
442                "headers": {"Authorization": "Bearer {env:EXAMPLE_TOKEN}"},
443            })
444        );
445    }
446
447    #[test]
448    fn opencode_takes_policies_and_mcp_servers_only() {
449        assert_eq!(
450            SUPPORTED_TYPES,
451            [CapabilityType::Policy, CapabilityType::McpServer]
452        );
453        assert!(
454            HOOK_COMPATIBILITY
455                .events
456                .iter()
457                .all(|entry| entry.coverage == CoverageLevel::Unsupported)
458        );
459    }
460}