use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::{Value, json};
use super::{HookFormat, HookInput, HookResponse, InstallOutcome, ParseError, Target, allow_reason};
use crate::verdict::Verdict;
pub struct CopilotTarget;
impl Target for CopilotTarget {
fn name(&self) -> &'static str {
"copilot"
}
fn display_name(&self) -> &'static str {
"GitHub Copilot CLI"
}
fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
vec![home.join(".github").join("hooks")]
}
fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
let dir = home.join(".github").join("hooks");
if let Err(e) = std::fs::create_dir_all(&dir) {
return Err(format!("Could not create {}: {e}", dir.display()));
}
let path = dir.join("safe-chains.json");
if path.exists() {
let contents = std::fs::read_to_string(&path)
.map_err(|e| format!("Could not read {}: {e}", path.display()))?;
let settings: Value = serde_json::from_str(&contents)
.map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
if has_safe_chains_hook(&settings) {
return Ok(InstallOutcome::AlreadyConfigured { path });
}
}
let settings = build_settings();
let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
std::fs::write(&path, format!("{output}\n"))
.map_err(|e| format!("Could not write {}: {e}", path.display()))?;
Ok(InstallOutcome::Installed { path })
}
fn hook_format(&self) -> Option<&dyn HookFormat> {
Some(&CopilotHookFormat)
}
}
struct CopilotHookFormat;
#[derive(Deserialize)]
struct CopilotHookEnvelope {
#[serde(default)]
#[serde(rename = "toolName")]
tool_name: Option<String>,
#[serde(default)]
#[serde(rename = "toolArgs")]
tool_args: Option<String>,
#[serde(default)]
cwd: Option<String>,
}
#[derive(Deserialize)]
struct CopilotToolArgs {
#[serde(default)]
command: Option<String>,
}
impl HookFormat for CopilotHookFormat {
fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
let envelope: CopilotHookEnvelope =
serde_json::from_str(stdin).map_err(|e| ParseError {
message: e.to_string(),
})?;
let is_bash_tool = envelope
.tool_name
.as_deref()
.is_some_and(|n| n == "bash");
if !is_bash_tool {
return Err(ParseError {
message: format!(
"not a bash tool: {:?}",
envelope.tool_name.as_deref().unwrap_or("<missing>")
),
});
}
let raw_args = envelope.tool_args.unwrap_or_default();
let inner: CopilotToolArgs =
serde_json::from_str(&raw_args).map_err(|e| ParseError {
message: format!("toolArgs not a parseable JSON string: {e}"),
})?;
Ok(HookInput {
command: inner.command.unwrap_or_default(),
cwd: envelope.cwd,
})
}
fn render_response(&self, verdict: Verdict) -> HookResponse {
if verdict.is_allowed() {
let reason = allow_reason(verdict);
let body = json!({
"permissionDecision": "allow",
"permissionDecisionReason": reason,
});
HookResponse {
stdout: serde_json::to_string(&body).unwrap_or_default(),
exit_code: 0,
}
} else {
HookResponse {
stdout: String::new(),
exit_code: 0,
}
}
}
}
fn build_settings() -> Value {
let resolved = std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
.map(|p| format!("{} hook copilot", p.display()))
.unwrap_or_else(|| "safe-chains hook copilot".to_string());
json!({
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": resolved,
"comment": "safe-chains: validate every Bash tool call before it runs.",
"timeoutSec": 60,
}
]
}
})
}
fn has_safe_chains_hook(settings: &Value) -> bool {
settings
.pointer("/hooks/preToolUse")
.and_then(|arr| arr.as_array())
.is_some_and(|entries| {
entries.iter().any(|entry| {
entry
.get("bash")
.and_then(|c| c.as_str())
.is_some_and(|cmd| cmd.contains("safe-chains"))
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::verdict::SafetyLevel;
fn target() -> CopilotTarget {
CopilotTarget
}
const COPILOT_DOCS_SAMPLE: &str = r#"{
"timestamp": 1704614600000,
"cwd": "/path/to/project",
"toolName": "bash",
"toolArgs": "{\"command\":\"ls -la\",\"description\":\"list files\"}"
}"#;
#[test]
fn install_creates_hooks_file() {
let dir = tempfile::tempdir().unwrap();
let outcome = target().install(dir.path()).unwrap();
assert!(matches!(outcome, InstallOutcome::Installed { .. }));
let path = dir.path().join(".github/hooks/safe-chains.json");
assert!(path.exists());
let contents = std::fs::read_to_string(&path).unwrap();
let settings: Value = serde_json::from_str(&contents).unwrap();
assert!(has_safe_chains_hook(&settings));
}
#[test]
fn install_uses_bash_field_not_command() {
let dir = tempfile::tempdir().unwrap();
target().install(dir.path()).unwrap();
let contents = std::fs::read_to_string(
dir.path().join(".github/hooks/safe-chains.json"),
)
.unwrap();
let settings: Value = serde_json::from_str(&contents).unwrap();
let entry = settings.pointer("/hooks/preToolUse/0").unwrap();
assert!(entry.get("bash").is_some(), "must use `bash` key");
assert!(entry.get("command").is_none(), "must NOT use `command` key");
}
#[test]
fn install_uses_subcommand_invocation() {
let dir = tempfile::tempdir().unwrap();
target().install(dir.path()).unwrap();
let contents = std::fs::read_to_string(
dir.path().join(".github/hooks/safe-chains.json"),
)
.unwrap();
assert!(contents.contains("hook copilot"));
}
#[test]
fn install_idempotent() {
let dir = tempfile::tempdir().unwrap();
target().install(dir.path()).unwrap();
let outcome = target().install(dir.path()).unwrap();
assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
}
#[test]
fn parse_input_double_decodes_tool_args() {
let parsed = CopilotHookFormat.parse_input(COPILOT_DOCS_SAMPLE).unwrap();
assert_eq!(parsed.command, "ls -la");
assert_eq!(parsed.cwd.as_deref(), Some("/path/to/project"));
}
#[test]
fn parse_input_skips_non_bash_tools() {
let stdin = r#"{
"timestamp": 1,
"cwd": "/p",
"toolName": "edit",
"toolArgs": "{\"path\":\"x\"}"
}"#;
assert!(CopilotHookFormat.parse_input(stdin).is_err());
}
#[test]
fn parse_input_rejects_garbage() {
assert!(CopilotHookFormat.parse_input("not json").is_err());
}
#[test]
fn parse_input_rejects_unparseable_tool_args() {
let stdin = r#"{"toolName": "bash", "toolArgs": "not-json"}"#;
let result = CopilotHookFormat.parse_input(stdin);
assert!(result.is_err());
}
#[test]
fn render_response_emits_flat_object_no_wrapper() {
let r = CopilotHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
let v: Value = serde_json::from_str(&r.stdout).unwrap();
assert_eq!(
v.get("permissionDecision").and_then(|s| s.as_str()),
Some("allow"),
);
assert!(
v.get("hookSpecificOutput").is_none(),
"must NOT wrap in hookSpecificOutput",
);
}
#[test]
fn render_response_includes_reason() {
let r = CopilotHookFormat.render_response(Verdict::Allowed(SafetyLevel::SafeWrite));
let v: Value = serde_json::from_str(&r.stdout).unwrap();
assert!(
v.get("permissionDecisionReason")
.and_then(|s| s.as_str())
.is_some()
);
}
#[test]
fn render_response_deny_emits_empty_body() {
let r = CopilotHookFormat.render_response(Verdict::Denied);
assert_eq!(r.stdout, "");
}
}