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
17pub const SUPPORTED_TYPES: &[CapabilityType] = &[CapabilityType::Policy];
20
21pub const SUPPORTED_AGENTS: &[&str] = &["OpenCode"];
22
23pub 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
46pub 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
62pub 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
143pub 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
166fn 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
182pub 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}