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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
//! `GitCaveats` — the custom OCAP capability surface for the embedded git tool.
//!
//! Git authority does not map onto the five [`Caveats`](crate::caveats::Caveats)
//! axes (`fs_*`/`exec`/`net` are path/program/host granular; git's boundaries are
//! *operation-class* and *ref/remote-scoped*). So `GitCaveats` is a **separate**
//! small lattice, composed *alongside* the signed `Caveats` wire type — never
//! merged into it (`Caveats` is the signed agent-mesh type, issue #95). It mirrors
//! the same `top`/`leq`/`meet` algebra, so it can only **attenuate**, never
//! amplify, and composes by intersection.
//!
//! The embedded git tool runs an operation iff `GitCaveats` permits the
//! operation *class* AND the session `Caveats` permits the underlying fs/net
//! effect — defense-in-depth by `meet` across two lattices (the tool layer gates
//! the repo path via `permits_fs_*`; this type gates the git op).
//!
//! **Network is deferred / fail-closed.** `remote`/`fetch`/`push`/`clone` are NOT
//! part of [`GitCaveats::top`]: remote git carries credentials + pulls untrusted
//! bytes, exactly what the OCAP deviation ratchet's `b1-os-isolation` /
//! `disclosure-gate-live-path` disable while open (`docs/security/ocap-deviations.md`).
//! The engine layer adds the `OCAP-GATE` fail-closed check; this type keeps the
//! capability off by default so "full local git" never implies any network reach.
use crate::caveats::{Caveats, Scope, ScopeExt};
use serde::{Deserialize, Serialize};
/// `a ⊑ b` for a boolean op-gate: `a` grants no more than `b` (false ⊑ true).
#[inline]
fn gate_leq(a: bool, b: bool) -> bool {
!a || b
}
/// A capability surface for the embedded git engine. Composed with — never merged
/// into — the session [`Caveats`]. Every axis is a lattice element; the effective
/// surface is always `a.meet(&b)` (attenuation only).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitCaveats {
/// Read history: `status`/`log`/`diff`/`show`/`blame`. The floor capability.
pub read: bool,
/// Mutate the index / worktree locally: `add`/`reset`/`restore`/`stash`.
pub stage: bool,
/// Create commits in the LOCAL object store (`commit`/`commit --amend`).
pub commit_local: bool,
/// Ref lifecycle (`branch`/`tag`/`update-ref`), scoped by ref-name pattern.
pub refs: Scope<String>,
/// Remote operations scoped by remote NAME (`origin`, `upstream`, …).
/// **Default `none`** — the network axis is deferred / fail-closed.
pub remote: Scope<String>,
/// Remote verb: may `fetch` (read from a remote). Default off.
pub fetch: bool,
/// Remote verb: may `push` (write to a remote). Default off.
pub push: bool,
/// Remote verb: may `clone` a remote. Default off.
pub clone: bool,
}
impl GitCaveats {
/// `⊤` for the **local** surface: all local ops allowed, all refs, but the
/// **network axis stays closed** (`remote = none`, verbs off). Network is not
/// part of "top" — it ships fail-closed under the OCAP deviation ratchet.
#[must_use]
pub fn top() -> Self {
Self {
read: true,
stage: true,
commit_local: true,
refs: Scope::top(),
remote: Scope::none(),
fetch: false,
push: false,
clone: false,
}
}
/// A read-only surface: history only, no mutation, no network. The natural
/// reviewer/triage grant.
#[must_use]
pub fn read_only() -> Self {
Self {
read: true,
stage: false,
commit_local: false,
refs: Scope::none(),
remote: Scope::none(),
fetch: false,
push: false,
clone: false,
}
}
/// `⊥` — no git authority at all (every gate denied).
#[must_use]
pub fn none() -> Self {
Self {
read: false,
stage: false,
commit_local: false,
refs: Scope::none(),
remote: Scope::none(),
fetch: false,
push: false,
clone: false,
}
}
/// `self ⊑ other` — does `self` grant no more than `other` on every axis?
/// The attenuation check (a delegated git surface must be `⊑` its parent).
#[must_use]
pub fn leq(&self, other: &Self) -> bool {
gate_leq(self.read, other.read)
&& gate_leq(self.stage, other.stage)
&& gate_leq(self.commit_local, other.commit_local)
&& self.refs.leq(&other.refs)
&& self.remote.leq(&other.remote)
&& gate_leq(self.fetch, other.fetch)
&& gate_leq(self.push, other.push)
&& gate_leq(self.clone, other.clone)
}
/// `self ⊓ other` — the greatest lower bound, axis by axis. How two git
/// surfaces compose; it can never amplify.
#[must_use]
pub fn meet(&self, other: &Self) -> Self {
Self {
read: self.read && other.read,
stage: self.stage && other.stage,
commit_local: self.commit_local && other.commit_local,
refs: self.refs.meet(&other.refs),
remote: self.remote.meet(&other.remote),
fetch: self.fetch && other.fetch,
push: self.push && other.push,
clone: self.clone && other.clone,
}
}
/// Derive a git surface from the session [`Caveats`] (the zero-config MVP
/// wiring): readable always (history is low-risk and the tool layer still
/// gates the repo path via `fs_read`); local mutation allowed iff the session
/// can write *somewhere* (`fs_write` is non-empty); network always denied
/// (deferred). A config-declared clamp can attenuate this further later.
#[must_use]
pub fn from_session(c: &Caveats) -> Self {
let can_write = match &c.fs_write {
Scope::All => true,
Scope::Only(set) => !set.is_empty(),
};
Self {
read: true,
stage: can_write,
commit_local: can_write,
refs: if can_write {
Scope::top()
} else {
Scope::none()
},
remote: Scope::none(),
fetch: false,
push: false,
clone: false,
}
}
// --- enforcement adaptors (read like prose at the dispatch site) ---
#[must_use]
pub fn permits_read(&self) -> bool {
self.read
}
#[must_use]
pub fn permits_stage(&self) -> bool {
self.stage
}
#[must_use]
pub fn permits_commit(&self) -> bool {
self.commit_local
}
/// Does this surface permit creating/writing the ref `name` (e.g. `refs/heads/feat/x`)?
#[must_use]
pub fn permits_ref(&self, name: &str) -> bool {
self.refs.permits(&name.to_string())
}
/// Does this surface permit operating on the remote named `name`?
#[must_use]
pub fn permits_remote(&self, name: &str) -> bool {
self.remote.permits(&name.to_string())
}
#[must_use]
pub fn permits_fetch(&self, remote: &str) -> bool {
self.fetch && self.permits_remote(remote)
}
#[must_use]
pub fn permits_push(&self, remote: &str) -> bool {
self.push && self.permits_remote(remote)
}
#[must_use]
pub fn permits_clone(&self) -> bool {
self.clone
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn top_allows_local_but_no_network() {
let g = GitCaveats::top();
assert!(g.permits_read() && g.permits_stage() && g.permits_commit());
assert!(g.permits_ref("refs/heads/anything"));
// Network axis is closed even at "top".
assert!(!g.permits_push("origin"));
assert!(!g.permits_fetch("origin"));
assert!(!g.permits_clone());
assert!(!g.permits_remote("origin"));
}
#[test]
fn read_only_denies_all_mutation() {
let g = GitCaveats::read_only();
assert!(g.permits_read());
assert!(!g.permits_stage());
assert!(!g.permits_commit());
assert!(!g.permits_ref("refs/heads/x"));
assert!(!g.permits_push("origin"));
}
#[test]
fn meet_attenuates_never_amplifies() {
let local = GitCaveats::top();
let ro = GitCaveats::read_only();
let m = local.meet(&ro);
// meet with read-only collapses to read-only authority.
assert!(m.permits_read());
assert!(!m.permits_commit());
assert!(!m.permits_ref("refs/heads/x"));
// meet result is ⊑ both operands.
assert!(m.leq(&local) && m.leq(&ro));
}
#[test]
fn leq_is_the_attenuation_order() {
assert!(GitCaveats::none().leq(&GitCaveats::read_only()));
assert!(GitCaveats::read_only().leq(&GitCaveats::top()));
assert!(GitCaveats::none().leq(&GitCaveats::top()));
// top is NOT ⊑ read_only (it grants more locally).
assert!(!GitCaveats::top().leq(&GitCaveats::read_only()));
}
#[test]
fn ref_scope_is_pattern_bounded() {
let g = GitCaveats {
refs: Scope::only(["refs/heads/feat/a".to_string()]),
..GitCaveats::top()
};
assert!(g.permits_ref("refs/heads/feat/a"));
assert!(!g.permits_ref("refs/heads/main"));
}
#[test]
fn from_session_writable_grants_local_no_network() {
let g = GitCaveats::from_session(&Caveats::top());
assert!(g.permits_read() && g.permits_stage() && g.permits_commit());
assert!(g.permits_ref("refs/heads/x"));
// Network is always denied regardless of the session grant.
assert!(!g.permits_push("origin") && !g.permits_clone());
}
#[test]
fn from_session_readonly_when_no_fs_write() {
let ro_session = Caveats {
fs_write: Scope::none(),
..Caveats::top()
};
let g = GitCaveats::from_session(&ro_session);
assert!(g.permits_read());
assert!(!g.permits_stage(), "no fs_write -> no staging");
assert!(!g.permits_commit());
assert!(!g.permits_ref("refs/heads/x"));
}
#[test]
fn remote_verb_requires_both_verb_and_remote_name() {
// A surface that names origin AND allows push — both gates must hold.
let full = GitCaveats {
remote: Scope::only(["origin".to_string()]),
push: true,
..GitCaveats::top()
};
assert!(full.permits_push("origin"));
assert!(!full.permits_push("upstream"), "remote name not in scope");
let verb_only = GitCaveats {
push: true,
..GitCaveats::top()
}; // remote stays none()
assert!(
!verb_only.permits_push("origin"),
"verb without a remote name is inert"
);
}
#[test]
fn serde_roundtrip() {
let g = GitCaveats::top();
let json = serde_json::to_string(&g).unwrap();
let back: GitCaveats = serde_json::from_str(&json).unwrap();
assert_eq!(g, back);
}
}