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;
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 only. Skills reach OpenCode through the `open-agents` layout;
18/// OpenCode MCP servers and hooks are not managed by this adapter yet.
19pub const SUPPORTED_TYPES: &[CapabilityType] = &[CapabilityType::Policy];
20
21pub const SUPPORTED_AGENTS: &[&str] = &["OpenCode"];
22
23/// The OpenCode config file policy rules are compiled into. OpenCode loads
24/// `.opencode/opencode.json` after the project's `opencode.json` and applies
25/// the last permission rule that matches, so rules here take precedence over
26/// the project's own.
27pub const CONFIG_RELPATH: &str = ".opencode/opencode.json";
28const OPENCODE_PERMISSIONS_DOCS: &str = "https://opencode.ai/docs/permissions/";
29
30pub struct OpenCode;
31
32const fn unsupported_hook(event: HookEvent) -> CompatibilityEntry {
33    CompatibilityEntry {
34        event,
35        native_event: None,
36        aliases: &[],
37        coverage: CoverageLevel::Unsupported,
38        scope: &[],
39        caveat: Some("Tuff does not manage OpenCode hooks, which OpenCode loads as plugins."),
40        source: None,
41        since_harness_version: None,
42        until_harness_version: None,
43    }
44}
45
46/// Every event is unsupported: this adapter takes no hooks, and
47/// `tuff hooks spec` lists only adapters that do.
48pub const HOOK_COMPATIBILITY: CompatibilityMatrix = CompatibilityMatrix {
49    spec_version: SPEC_VERSION,
50    adapter: ID,
51    events: &[
52        unsupported_hook(HookEvent::SessionStart),
53        unsupported_hook(HookEvent::SessionEnd),
54        unsupported_hook(HookEvent::PreToolUse),
55        unsupported_hook(HookEvent::PostToolUse),
56        unsupported_hook(HookEvent::BeforeFinish),
57        unsupported_hook(HookEvent::AfterSave),
58        unsupported_hook(HookEvent::Stop),
59    ],
60};
61
62/// How OpenCode enforces each kind of policy rule, from its permissions
63/// documentation and source, checked against OpenCode 1.18.15 on 2026-09-15.
64pub fn policy_matrix() -> Vec<PolicyCoverageEntry> {
65    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";
66    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";
67    const READ: &str = "covers OpenCode's read tool; grep, glob, list, and shell commands are separate permissions and are not covered";
68    const EDIT: &str = "covers OpenCode's edit, write, and patch tools; shell commands that write files are not covered";
69    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 _";
70    const MCP_ASK: &str = "OpenCode names a tool <server>_<tool>, with characters other than letters, digits, _ and - replaced by _";
71    const ASK: &str = "opencode run rejects the request, and opencode --auto approves it";
72    let row = |effect, subject, coverage, mechanism: &str, caveat: String| PolicyCoverageEntry {
73        effect,
74        subject,
75        coverage,
76        mechanism: Some(mechanism.to_string()),
77        caveat: Some(caveat),
78        source: Some(OPENCODE_PERMISSIONS_DOCS.to_string()),
79    };
80    use CoverageLevel::{Full, Partial};
81    use PolicyEffect::{Ask, Deny};
82    use PolicySubjectKind::{Command, Edit, Mcp, Read};
83    vec![
84        row(
85            Deny,
86            Command,
87            Partial,
88            ".opencode/opencode.json permission.bash \"<command> *\": \"deny\"",
89            format!("{COMMAND}; {PRECEDENCE}"),
90        ),
91        row(
92            Deny,
93            Read,
94            Partial,
95            ".opencode/opencode.json permission.read \"<path>\": \"deny\"",
96            format!("{READ}; {PRECEDENCE}"),
97        ),
98        row(
99            Deny,
100            Edit,
101            Partial,
102            ".opencode/opencode.json permission.edit \"<path>\": \"deny\"",
103            format!("{EDIT}; {PRECEDENCE}"),
104        ),
105        row(
106            Deny,
107            Mcp,
108            Full,
109            ".opencode/opencode.json permission \"<server>_<tool>\": \"deny\"",
110            format!("{MCP_DENY}; {PRECEDENCE}"),
111        ),
112        row(
113            Ask,
114            Command,
115            Partial,
116            ".opencode/opencode.json permission.bash \"<command> *\": \"ask\"",
117            format!("{COMMAND}; {ASK}; {PRECEDENCE}"),
118        ),
119        row(
120            Ask,
121            Read,
122            Partial,
123            ".opencode/opencode.json permission.read \"<path>\": \"ask\"",
124            format!("{READ}; {ASK}; {PRECEDENCE}"),
125        ),
126        row(
127            Ask,
128            Edit,
129            Partial,
130            ".opencode/opencode.json permission.edit \"<path>\": \"ask\"",
131            format!("{EDIT}; {ASK}; {PRECEDENCE}"),
132        ),
133        row(
134            Ask,
135            Mcp,
136            Full,
137            ".opencode/opencode.json permission \"<server>_<tool>\": \"ask\"",
138            format!("{MCP_ASK}; {ASK}; {PRECEDENCE}"),
139        ),
140    ]
141}
142
143/// A policy path pattern, read as `.gitignore` reads a pattern at the
144/// project root, as OpenCode path patterns.
145///
146/// OpenCode matches a read or edit against the path relative to the
147/// project, and its `*` also matches `/`. A pattern with a `/` at its start
148/// or middle stays anchored, so `secrets/**` is kept. One without matches at
149/// any depth, which takes two patterns: `.env` and `*/.env`. A trailing `/`
150/// names a directory's contents.
151pub fn permission_paths(pattern: &str) -> Vec<String> {
152    let pattern = pattern.trim_start_matches("./");
153    let anchored = pattern.trim_end_matches('/').contains('/');
154    let normalized = if pattern.ends_with('/') {
155        format!("{pattern}*")
156    } else {
157        pattern.to_string()
158    };
159    if anchored {
160        vec![normalized]
161    } else {
162        vec![normalized.clone(), format!("*/{normalized}")]
163    }
164}
165
166/// A server or tool name as OpenCode writes it into a tool's permission
167/// name: characters other than letters, digits, `_`, and `-` become `_`. A
168/// policy's `*` is kept, since OpenCode matches permission names as
169/// patterns.
170fn tool_name_part(name: &str) -> String {
171    name.chars()
172        .map(|character| {
173            if character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '*') {
174                character
175            } else {
176                '_'
177            }
178        })
179        .collect()
180}
181
182/// The OpenCode rules one policy rule compiles to, as Tuff records them: a
183/// permission name, then a space and a pattern when the rule sits in that
184/// permission's object. The effect is the action the rule is written with.
185pub fn permission_rules(rule: &PolicyRule) -> Result<Vec<String>> {
186    Ok(match rule.subject()? {
187        PolicySubject::Command(arguments) => vec![format!("bash {} *", arguments.join(" "))],
188        PolicySubject::Read(patterns) => patterns
189            .iter()
190            .flat_map(|pattern| permission_paths(pattern))
191            .map(|pattern| format!("read {pattern}"))
192            .collect(),
193        PolicySubject::Edit(patterns) => patterns
194            .iter()
195            .flat_map(|pattern| permission_paths(pattern))
196            .map(|pattern| format!("edit {pattern}"))
197            .collect(),
198        PolicySubject::Mcp { server, tool } => {
199            vec![format!(
200                "{}_{}",
201                tool_name_part(server),
202                tool_name_part(tool)
203            )]
204        }
205    })
206}
207
208impl AgentAdapter for OpenCode {
209    fn id(&self) -> &'static str {
210        ID
211    }
212
213    fn display_name(&self) -> &'static str {
214        DISPLAY_NAME
215    }
216
217    fn dir_prefix(&self) -> &'static str {
218        ".opencode"
219    }
220
221    fn mcp_config_relpath(&self) -> &'static str {
222        "opencode.json"
223    }
224
225    fn supported_agents(&self) -> &[&'static str] {
226        SUPPORTED_AGENTS
227    }
228
229    fn kinds_supported(&self) -> &[CapabilityType] {
230        SUPPORTED_TYPES
231    }
232
233    fn hook_compatibility(&self) -> &'static CompatibilityMatrix {
234        &HOOK_COMPATIBILITY
235    }
236
237    fn hook_settings_relpath(&self) -> &'static str {
238        CONFIG_RELPATH
239    }
240
241    fn scaffold_hook_event(&self) -> &'static str {
242        ""
243    }
244
245    fn hook_settings_shape(&self) -> HookSettingsShape {
246        HookSettingsShape::Flat
247    }
248
249    fn policy_compatibility(&self) -> Vec<PolicyCoverageEntry> {
250        policy_matrix()
251    }
252
253    fn permissions_settings_relpath(&self) -> Option<&'static str> {
254        Some(CONFIG_RELPATH)
255    }
256
257    fn native_permission_rules(&self, rule: &PolicyRule) -> Result<Option<Vec<String>>> {
258        permission_rules(rule).map(Some)
259    }
260
261    fn detect(&self, repo_root: &Path) -> bool {
262        repo_root.join("opencode.json").exists()
263            || repo_root.join("opencode.jsonc").exists()
264            || repo_root.join(".opencode").exists()
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn rule(effect: &str) -> PolicyRule {
273        PolicyRule {
274            effect: effect.to_string(),
275            command: None,
276            read: None,
277            edit: None,
278            mcp: None,
279            reason: None,
280        }
281    }
282
283    fn words(words: &[&str]) -> Option<Vec<String>> {
284        Some(words.iter().map(|word| word.to_string()).collect())
285    }
286
287    #[test]
288    fn each_kind_of_rule_compiles_to_an_opencode_permission() {
289        let compiled = |rule: PolicyRule| permission_rules(&rule).unwrap();
290        assert_eq!(
291            compiled(PolicyRule {
292                command: words(&["git", "push", "--force"]),
293                ..rule("deny")
294            }),
295            ["bash git push --force *"]
296        );
297        assert_eq!(
298            compiled(PolicyRule {
299                read: words(&[".env", "secrets/**"]),
300                ..rule("deny")
301            }),
302            ["read .env", "read */.env", "read secrets/**"]
303        );
304        assert_eq!(
305            compiled(PolicyRule {
306                edit: words(&["infra/prod/", "certs/"]),
307                ..rule("ask")
308            }),
309            ["edit infra/prod/*", "edit certs/*", "edit */certs/*"]
310        );
311        assert_eq!(
312            compiled(PolicyRule {
313                mcp: Some("my.server:delete_*".to_string()),
314                ..rule("deny")
315            }),
316            ["my_server_delete_*"]
317        );
318    }
319
320    #[test]
321    fn the_policy_matrix_enforces_every_kind_of_rule() {
322        let matrix = OpenCode.policy_compatibility();
323        assert_eq!(matrix.len(), 8);
324        for entry in &matrix {
325            let expected = if entry.subject == PolicySubjectKind::Mcp {
326                CoverageLevel::Full
327            } else {
328                CoverageLevel::Partial
329            };
330            assert_eq!(entry.coverage, expected, "{entry:?}");
331            assert!(entry.caveat.is_some(), "{entry:?}");
332        }
333    }
334
335    #[test]
336    fn opencode_is_a_policy_only_target() {
337        assert_eq!(SUPPORTED_TYPES, [CapabilityType::Policy]);
338        assert!(
339            HOOK_COMPATIBILITY
340                .events
341                .iter()
342                .all(|entry| entry.coverage == CoverageLevel::Unsupported)
343        );
344    }
345}