1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
use std::path::{Path, PathBuf};
use crate::verdict::{SafetyLevel, Verdict};
pub mod agy;
pub mod claude;
pub mod codex;
pub mod copilot;
pub mod cursor;
pub mod droid;
pub mod gemini;
pub mod grok;
pub mod opencode;
pub mod qwen;
pub trait Target: Send + Sync {
fn name(&self) -> &'static str;
fn display_name(&self) -> &'static str;
fn detect_paths(&self, home: &Path) -> Vec<PathBuf>;
fn install(&self, home: &Path) -> Result<InstallOutcome, String>;
fn hook_format(&self) -> Option<&dyn HookFormat> {
None
}
}
pub trait HookFormat: Send + Sync {
fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError>;
fn render_response(&self, verdict: Verdict) -> HookResponse;
/// Surface explanatory context to the model on a non-approval *without*
/// changing the permission decision (the command still flows through the
/// tool's normal approval path, and the user's own allowlist still applies).
///
/// The default abstains silently — same as today's empty deny body. A target
/// overrides this only when its hook schema has a verified field for
/// injecting model-visible context without a permission decision.
fn render_context(&self, _context: &str) -> HookResponse {
HookResponse {
stdout: String::new(),
exit_code: 0,
}
}
/// How this harness's hook must handle a GATED command (one safe-chains does not auto-approve),
/// derived from its capabilities (`docs/design/harness-capability-model.md`):
/// - `Defer` — stay silent; the harness's own per-command human review is the check (Claude).
/// - `Deny` — veto it; the harness has no human review and no escalate (Codex).
/// - `Ask` — escalate to an in-the-moment human prompt (Antigravity's `ask`).
fn gated_policy(&self) -> GatedPolicy {
GatedPolicy::Defer
}
/// The hook output that VETOES a gated command, for a `Deny` harness. Default abstains (so a
/// stray call can't fail open). The shape must be exactly what the harness supports, or a
/// harness that "continues on malformed output" (e.g. Codex) fails open.
fn render_deny(&self, _reason: &str) -> HookResponse {
HookResponse {
stdout: String::new(),
exit_code: 0,
}
}
/// The hook output that ESCALATES a gated command to a human prompt, for an `Ask` harness.
/// Default abstains. (Antigravity fails CLOSED on a malformed/absent decision, so an Ask target
/// must always emit a valid decision.)
fn render_ask(&self, _reason: &str) -> HookResponse {
HookResponse {
stdout: String::new(),
exit_code: 0,
}
}
}
/// How a harness's hook handles a gated command — see `HookFormat::gated_policy`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum GatedPolicy {
Defer,
Deny,
Ask,
}
#[derive(Debug)]
pub struct ParseError {
pub message: String,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ParseError {}
pub struct HookInput {
pub command: String,
pub cwd: Option<String>,
/// The project root, when the harness supplies one (HP-19) — a `*_PROJECT_DIR` env var
/// for most, `workspace_roots` in the payload for cursor. Absent for codex/copilot.
pub root: Option<String>,
/// The harness's session/conversation id, when it supplies one (`session_id` for
/// Claude/Gemini/Qwen/Droid, `sessionId` for grok, `conversation_id` for cursor). It comes from
/// the harness's own envelope, so the agent cannot forge it — which is what makes it usable as
/// the anchor for recognizing the session's scratchpad (see `pathctx::session_scratchpad`).
pub session_id: Option<String>,
}
/// Read a harness project-root env var from the hook process environment (set by the
/// harness, not the agent's shell — see HARNESS-BEHAVIORS.md). Empty → `None`.
pub(crate) fn env_root(var: &str) -> Option<String> {
std::env::var(var).ok().filter(|s| !s.is_empty())
}
pub struct HookResponse {
pub stdout: String,
pub exit_code: i32,
}
pub enum InstallOutcome {
Installed { path: PathBuf },
AlreadyConfigured { path: PathBuf },
Skipped { reason: String },
}
impl InstallOutcome {
pub fn message(&self, target_display: &str) -> String {
match self {
InstallOutcome::Installed { path } => {
format!("{target_display}: installed → {}", path.display())
}
InstallOutcome::AlreadyConfigured { path } => {
format!("{target_display}: already configured at {}", path.display())
}
InstallOutcome::Skipped { reason } => {
format!("{target_display}: skipped — {reason}")
}
}
}
}
pub fn registry() -> Vec<Box<dyn Target>> {
vec![
Box::new(claude::ClaudeTarget),
Box::new(codex::CodexTarget),
Box::new(agy::AntigravityTarget),
Box::new(cursor::CursorTarget),
Box::new(gemini::GeminiTarget),
Box::new(grok::GrokTarget),
Box::new(copilot::CopilotTarget),
Box::new(qwen::QwenTarget),
Box::new(droid::DroidTarget),
Box::new(opencode::OpenCodeTarget),
]
}
pub fn find(name: &str) -> Option<Box<dyn Target>> {
registry().into_iter().find(|t| t.name() == name)
}
pub fn detect_installed(home: &Path) -> Vec<Box<dyn Target>> {
registry()
.into_iter()
.filter(|t| t.detect_paths(home).iter().any(|p| p.exists()))
.collect()
}
pub fn allow_reason(verdict: Verdict) -> &'static str {
match verdict {
Verdict::Allowed(SafetyLevel::SafeWrite) => {
"All commands in chain are safe utilities (includes file writes)"
}
Verdict::Allowed(SafetyLevel::SafeRead) => {
"All commands in chain are safe utilities (includes code execution)"
}
_ => "All commands in chain are safe utilities",
}
}