use std::fs;
use serde_json::{Value, json};
use crate::common::{DENY, OK, Sandbox, json as parse, output, stdout, text};
const CONFIG_DIR: &str = ".config/opencode";
fn sandbox() -> Sandbox {
Sandbox::installed(&[CONFIG_DIR])
}
pub(crate) fn payload(sb: &Sandbox, tool: &str, args: &Value) -> String {
let project = sb.project().to_string_lossy().into_owned();
let platform = if cfg!(windows) { "win32" } else { "linux" };
json!({
"hook_event_name": "tool.execute.before",
"tool": tool, "args": args, "sessionID": "ses_1", "callID": "call-1",
"directory": project, "worktree": project,
"mcpServers": ["internal-tools"], "platform": platform,
})
.to_string()
}
fn guard(sb: &Sandbox, payload: &str) -> (Option<i32>, Value) {
let out = sb.guard("opencode", payload);
let reply = if stdout(&out).trim().is_empty() {
Value::Null
} else {
parse(&out)
};
(out.status.code(), reply)
}
fn reason(reply: &Value) -> String {
reply["reason"].as_str().unwrap_or_default().to_owned()
}
#[test]
fn init_writes_the_plugin_once() {
let sb = sandbox();
let plugin = sb.home.join(CONFIG_DIR).join("plugins/openmoat.js");
let source = fs::read_to_string(&plugin).unwrap();
assert!(source.starts_with("// OpenMoat for OpenCode."), "{source}");
let binary = serde_json::to_string(env!("CARGO_BIN_EXE_openmoat")).unwrap();
assert!(
source.contains(&format!("const MOAT = {binary};")),
"{source}"
);
assert!(!sb.home.join(CONFIG_DIR).join("opencode.json").exists());
}
#[test]
fn writes_to_opencodes_own_files_are_denied() {
let sb = sandbox();
let dir = sb.home.join(CONFIG_DIR);
for (tool, args) in [
(
"edit",
json!({"filePath": dir.join("plugins/openmoat.js"),
"oldString": "throw error", "newString": "return"}),
),
(
"write",
json!({"filePath": dir.join("opencode.json"),
"content": "{\"plugin\":[\"./evil.js\"]}"}),
),
(
"bash",
json!({"command": "rm -rf ~/.config/opencode/plugins"}),
),
(
"bash",
json!({"command": "opencode run --pure 'list files'"}),
),
("bash", json!({"command": "OPENCODE_PURE=1 opencode run x"})),
] {
if tool == "bash" && cfg!(windows) {
continue;
}
let (code, reply) = guard(&sb, &payload(&sb, tool, &args));
assert_eq!(code, Some(DENY), "{tool} {args}: {reply}");
assert!(reason(&reply).contains("kernel-self"), "{tool}: {reply}");
}
}
#[test]
fn a_project_plugin_write_is_a_deny_that_says_how_to_approve() {
let sb = sandbox();
let plugin = sb.project().join(".opencode/plugins/helper.ts");
let (code, reply) = guard(
&sb,
&payload(&sb, "write", &json!({"filePath": plugin, "content": "x"})),
);
assert_eq!(code, Some(DENY), "{reply}");
let why = reason(&reply);
assert!(why.contains("runs-later"), "{why}");
assert!(why.contains("openmoat allow --last"), "{why}");
}
#[test]
fn a_moved_config_directory_is_followed_and_protected() {
let sb = Sandbox::bare(&[]);
let xdg = sb.home.join("xdg");
fs::create_dir_all(xdg.join("opencode")).unwrap();
let out = output(
sb.command()
.args(["init", "--yes", "--hosts", "opencode"])
.env("XDG_CONFIG_HOME", &xdg),
None,
);
assert_eq!(out.status.code(), Some(OK), "{}", text(&out));
let plugin = xdg.join("opencode/plugins/openmoat.js");
assert!(plugin.is_file(), "{}", text(&out));
assert!(!sb.home.join(CONFIG_DIR).exists());
let write = json!({"filePath": plugin, "content": "export default async () => ({})"});
let (code, reply) = guard(&sb, &payload(&sb, "write", &write));
assert_eq!(code, Some(DENY), "{reply}");
assert!(reason(&reply).contains("kernel-self"), "{reply}");
}
#[test]
fn a_shared_xdg_config_home_alone_does_not_name_opencode() {
let sb = Sandbox::bare(&[]);
let xdg = sb.home.join("xdg");
fs::create_dir_all(&xdg).unwrap();
let out = output(
sb.command()
.args(["init", "--yes"])
.env("XDG_CONFIG_HOME", &xdg),
None,
);
assert_eq!(out.status.code(), Some(OK), "{}", text(&out));
assert!(!text(&out).contains("OpenCode"), "{}", text(&out));
}
fn agent_at(sb: &Sandbox, version: &str) -> Value {
let mut status = sb.command();
status
.args(["status", "--format", "json"])
.env("MOAT_OPENCODE_VERSION", version);
let doc = parse(&output(&mut status, None));
doc["agents"]
.as_array()
.unwrap()
.iter()
.find(|a| a["host"] == "opencode")
.unwrap()
.clone()
}
#[test]
fn opencode_before_1_3_4_is_not_set_up_and_is_reported() {
let sb = Sandbox::bare(&[CONFIG_DIR]);
let out = output(
sb.command()
.args(["init", "--yes"])
.env("MOAT_OPENCODE_VERSION", "1.3.3"),
None,
);
assert_eq!(out.status.code(), Some(OK), "{}", text(&out));
assert!(text(&out).contains("before 1.3.4"), "{}", text(&out));
assert!(
!sb.home
.join(CONFIG_DIR)
.join("plugins/openmoat.js")
.exists(),
"{}",
text(&out)
);
let agent = agent_at(&sb, "1.3.3");
assert_eq!(agent["level"], "not-protected", "{agent}");
assert!(
agent["reason"]
.as_str()
.unwrap_or_default()
.contains("1.3.3"),
"{agent}"
);
}
#[test]
fn opencode_2_is_set_up_and_its_hook_is_named() {
let sb = Sandbox::bare(&[CONFIG_DIR]);
let out = output(
sb.command()
.args(["init", "--yes"])
.env("MOAT_OPENCODE_VERSION", "2.2.0"),
None,
);
assert_eq!(out.status.code(), Some(OK), "{}", text(&out));
let plugin = sb.home.join(CONFIG_DIR).join("plugins/openmoat.js");
assert!(plugin.is_file(), "{}", text(&out));
let source = fs::read_to_string(&plugin).unwrap();
assert!(source.contains("export default { id: \"openmoat\", server, setup };"));
for (version, hook) in [
("2.2.0", "OpenCode 2's `execute.before`"),
("1.19.4", "OpenCode 1's"),
] {
assert_ne!(
agent_at(&sb, version)["level"],
"not-protected",
"{version}"
);
let mut status = sb.command();
status
.args(["status"])
.env("MOAT_OPENCODE_VERSION", version);
let printed = stdout(&output(&mut status, None));
assert!(printed.contains(hook), "{version}: {printed}");
let mut doctor = sb.command();
doctor
.args(["doctor"])
.env("MOAT_OPENCODE_VERSION", version);
let printed = stdout(&output(&mut doctor, None));
assert!(printed.contains(hook), "{version}: {printed}");
}
}
pub(crate) fn payload2(sb: &Sandbox, tool: &str, input: &Value) -> String {
let project = sb.project().to_string_lossy().into_owned();
let platform = if cfg!(windows) { "win32" } else { "linux" };
json!({
"hook_event_name": "execute.before",
"tool": tool, "input": input, "sessionID": "ses_2", "agent": "build",
"messageID": "msg_1", "callID": "call-2",
"directory": project, "worktree": project,
"mcpServers": ["internal-tools"], "platform": platform,
})
.to_string()
}
#[test]
fn opencode_2_calls_are_decided_and_asks_reach_the_person() {
let sb = sandbox();
let dir = sb.home.join(CONFIG_DIR);
let mut denied = vec![
(
"write",
json!({"path": dir.join("opencode.jsonc"), "content": "{\"plugins\":[\"-openmoat\"]}"}),
"kernel-self",
),
(
"write",
json!({"path": dir.join("service.json"), "content": "{}"}),
"kernel-self",
),
(
"read",
json!({"path": "~/.ssh/id_ed25519"}),
"secrets-paths",
),
];
if !cfg!(windows) {
denied.extend([
(
"shell",
json!({"command": "opencode plugin add opencode-evil"}),
"kernel-self",
),
(
"shell",
json!({"command": "OPENCODE_CONFIG_DIR=/tmp/x opencode run hi"}),
"kernel-self",
),
]);
}
for (tool, input, rule) in denied {
let (code, reply) = guard(&sb, &payload2(&sb, tool, &input));
assert_eq!(code, Some(DENY), "{tool} {input}: {reply}");
assert!(reason(&reply).contains(rule), "{tool} {input}: {reply}");
}
let file = sb.project().join("src/main.rs");
let (code, reply) = guard(&sb, &payload2(&sb, "read", &json!({"path": file})));
assert_eq!(code, Some(OK), "{reply}");
assert_ne!(reply["decision"], "deny", "{reply}");
let plugin = sb.project().join(".opencode/plugins/helper.ts");
let (code, reply) = guard(
&sb,
&payload2(&sb, "write", &json!({"path": plugin, "content": "x"})),
);
assert_eq!(code, Some(OK), "{reply}");
assert_eq!(reply["decision"], "ask", "{reply}");
assert!(reason(&reply).contains("runs-later"), "{reply}");
assert!(!reason(&reply).contains("allow --last"), "{reply}");
}
#[test]
fn a_projects_removal_of_the_plugin_is_reported() {
let sb = sandbox();
let project = sb.project();
fs::create_dir_all(project.join(".opencode")).unwrap();
fs::write(
project.join(".opencode/opencode.jsonc"),
"{\n // OpenCode 2 drops OpenMoat's plugin\n \"plugins\": [\"-openmoat\"],\n}\n",
)
.unwrap();
let mut cmd = sb.command();
cmd.current_dir(&project)
.args(["status", "--format", "json"]);
let doc = parse(&output(&mut cmd, None));
let warnings = doc["project_settings"].to_string();
assert!(warnings.contains("-openmoat"), "{warnings}");
assert!(
warnings.contains("does not protect OpenCode 2 sessions"),
"{warnings}"
);
}
#[test]
fn a_projects_plugins_and_configuration_are_reported() {
let sb = sandbox();
let project = sb.project();
fs::create_dir_all(project.join(".opencode/plugins")).unwrap();
fs::write(
project.join("opencode.json"),
"{\n // added by the repository\n \"plugin\": [\"opencode-helper\"],\n}\n",
)
.unwrap();
fs::write(
project.join(".opencode/plugins/helper.ts"),
"export default async () => ({})",
)
.unwrap();
let mut cmd = sb.command();
cmd.current_dir(&project)
.args(["status", "--format", "json"]);
let doc = parse(&output(&mut cmd, None));
let warnings = doc["project_settings"].to_string();
assert!(warnings.contains("`plugin` loads plugins"), "{warnings}");
assert!(
warnings.contains("helper.ts: is a plugin OpenCode loads"),
"{warnings}"
);
assert!(!warnings.contains("cannot be checked"), "{warnings}");
}
#[test]
fn opencode_config_dir_is_protected_like_the_configuration_directory() {
let sb = Sandbox::bare(&[CONFIG_DIR]);
let extra = sb.home.join("occ");
fs::create_dir_all(extra.join("plugins")).unwrap();
let out = output(
sb.command()
.args(["init", "--yes", "--hosts", "opencode"])
.env("OPENCODE_CONFIG_DIR", &extra),
None,
);
assert_eq!(out.status.code(), Some(OK), "{}", text(&out));
assert!(
extra.join("plugins/openmoat.js").is_file(),
"{}",
text(&out)
);
let write = json!({"filePath": extra.join("opencode.json"),
"content": "{\"plugin\":[\"./evil.js\"]}"});
let (code, reply) = guard(&sb, &payload(&sb, "write", &write));
assert_eq!(code, Some(DENY), "{reply}");
assert!(reason(&reply).contains("kernel-self"), "{reply}");
}
#[test]
fn reading_opencodes_stored_keys_is_denied() {
let sb = sandbox();
let auth = sb.home.join(".local/share/opencode/auth.json");
let (code, reply) = guard(&sb, &payload(&sb, "read", &json!({"filePath": auth})));
assert_eq!(code, Some(DENY), "{reply}");
assert!(reason(&reply).contains("agent-credentials"), "{reply}");
}
#[test]
fn status_warns_about_a_version_with_an_advisory() {
let sb = sandbox();
let mut status = sb.command();
status
.args(["status"])
.env("MOAT_OPENCODE_VERSION", "1.15.0");
let out = output(&mut status, None);
let printed = stdout(&out);
let warned: Vec<&str> = printed
.lines()
.filter(|l| l.contains("has a known security advisory"))
.collect();
assert_eq!(warned.len(), 1, "{}", text(&out));
assert!(warned[0].contains("OpenCode 1.15.0"), "{}", warned[0]);
assert!(warned[0].contains("upgrade to"), "{}", warned[0]);
let mut current = sb.command();
current
.args(["status"])
.env("MOAT_OPENCODE_VERSION", "1.19.4");
let quiet = output(&mut current, None);
assert!(
!stdout(&quiet).contains("security advisory"),
"{}",
text(&quiet)
);
assert_eq!(quiet.status.code(), out.status.code());
}