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
17pub const SUPPORTED_TYPES: &[CapabilityType] = &[CapabilityType::Policy, CapabilityType::McpServer];
21
22pub const SUPPORTED_AGENTS: &[&str] = &["OpenCode"];
23
24pub 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
47pub 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
63pub 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
144pub 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
167fn 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
183pub 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 fn mcp_config_relpath(&self) -> &'static str {
226 CONFIG_RELPATH
227 }
228
229 fn mcp_env_reference(&self, var: &str) -> String {
231 format!("{{env:{var}}}")
232 }
233
234 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}