1use std::error::Error;
2use std::fmt;
3
4use vsh_commit::RecoveryReport;
5use vsh_monty::ExecutionStats;
6use vsh_policy::{PolicyProfile, PolicyThresholds, RiskFlag, RiskMetrics};
7use vsh_store::TransactionRecord;
8use vsh_types::{
9 BlobId, DiffDigest, DiffEntry, HookId, IntentDigest, PolicyDigest, PrincipalId, ProgramDigest,
10 ReadSetDigest, RequestEventId, RuntimeConfigDigest, SnapshotId, TransactionId,
11 TransactionState, VPath, WriteSetDigest,
12};
13use vsh_vfs::EffectEvent;
14
15use crate::runtime::{Receipt, RunMode, RunRequest, Runtime, RuntimeConfig, VshError};
16
17#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19pub enum HookScope {
20 #[default]
22 ReviewRequired,
23 AllRequests,
25}
26
27impl HookScope {
28 pub(crate) const fn tag(self) -> u8 {
29 match self {
30 Self::ReviewRequired => 1,
31 Self::AllRequests => 2,
32 }
33 }
34
35 pub(crate) const fn applies_to(self, state: TransactionState) -> bool {
36 match self {
37 Self::ReviewRequired => matches!(state, TransactionState::PendingApproval),
38 Self::AllRequests => matches!(
39 state,
40 TransactionState::AutoApproved | TransactionState::PendingApproval
41 ),
42 }
43 }
44}
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48pub struct HookConfig {
49 id: HookId,
50 scope: HookScope,
51 approval_ttl_ms: u64,
52 max_reason_bytes: usize,
53 max_content_bytes: usize,
54}
55
56impl HookConfig {
57 #[must_use]
59 pub fn new(label: &str) -> Self {
60 Self {
61 id: HookId::digest_label(label),
62 scope: HookScope::ReviewRequired,
63 approval_ttl_ms: 5 * 60 * 1_000,
64 max_reason_bytes: 16 * 1_024,
65 max_content_bytes: 0,
66 }
67 }
68
69 #[must_use]
71 pub const fn with_scope(mut self, scope: HookScope) -> Self {
72 self.scope = scope;
73 self
74 }
75
76 #[must_use]
78 pub const fn with_approval_ttl_ms(mut self, approval_ttl_ms: u64) -> Self {
79 self.approval_ttl_ms = approval_ttl_ms;
80 self
81 }
82
83 #[must_use]
85 pub const fn with_max_reason_bytes(mut self, max_reason_bytes: usize) -> Self {
86 self.max_reason_bytes = max_reason_bytes;
87 self
88 }
89
90 #[must_use]
94 pub const fn with_max_content_bytes(mut self, maximum: usize) -> Self {
95 self.max_content_bytes = maximum;
96 self
97 }
98
99 #[must_use]
101 pub const fn max_content_bytes(self) -> usize {
102 self.max_content_bytes
103 }
104
105 #[must_use]
107 pub const fn id(self) -> HookId {
108 self.id
109 }
110
111 #[must_use]
113 pub const fn scope(self) -> HookScope {
114 self.scope
115 }
116
117 #[must_use]
119 pub const fn approval_ttl_ms(self) -> u64 {
120 self.approval_ttl_ms
121 }
122
123 #[must_use]
125 pub const fn max_reason_bytes(self) -> usize {
126 self.max_reason_bytes
127 }
128
129 pub(crate) fn principal(self) -> PrincipalId {
130 PrincipalId::from_bytes(*self.id.as_bytes())
131 }
132}
133
134impl Default for HookConfig {
135 fn default() -> Self {
136 Self::new("vsh.commit-hook")
137 }
138}
139
140#[derive(Clone, Copy, Debug, Eq, PartialEq)]
142pub enum HookBaseline {
143 AutoApproved,
145 ReviewRequired,
147}
148
149#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct ReviewContent {
152 pub path: VPath,
154 pub blob: BlobId,
156 pub bytes: Vec<u8>,
158}
159
160#[derive(Clone, Debug, Eq, PartialEq)]
162pub struct RequestEvent {
163 pub schema_version: u16,
165 pub event_id: RequestEventId,
167 pub hook_id: HookId,
169 pub transaction: TransactionId,
171 pub state: TransactionState,
173 pub baseline: HookBaseline,
175 pub base_snapshot: SnapshotId,
177 pub diff: DiffDigest,
179 pub read_set: ReadSetDigest,
181 pub write_set: WriteSetDigest,
183 pub program: ProgramDigest,
185 pub policy: PolicyDigest,
187 pub runtime_config: RuntimeConfigDigest,
189 pub intent_digest: Option<IntentDigest>,
191 pub intent: Option<String>,
193 pub policy_profile: PolicyProfile,
195 pub policy_thresholds: PolicyThresholds,
197 pub risk_metrics: RiskMetrics,
199 pub risk_flags: Vec<RiskFlag>,
201 pub canonical_diff: Vec<DiffEntry>,
203 pub effects: Vec<EffectEvent>,
205 pub execution: ExecutionStats,
207 pub evidence_complete: bool,
209 pub evidence_truncated: bool,
211 pub contents: Vec<ReviewContent>,
213 pub content_complete: bool,
218}
219
220#[derive(Clone, Debug, Eq, PartialEq)]
222pub enum HookDecision {
223 FollowPolicy,
225 Approve {
227 reason: String,
229 },
230 Review {
232 feedback: String,
234 },
235 Reject {
237 reason: String,
239 },
240}
241
242impl HookDecision {
243 #[must_use]
245 pub fn approve(reason: impl Into<String>) -> Self {
246 Self::Approve {
247 reason: reason.into(),
248 }
249 }
250
251 #[must_use]
253 pub fn review(feedback: impl Into<String>) -> Self {
254 Self::Review {
255 feedback: feedback.into(),
256 }
257 }
258
259 #[must_use]
261 pub fn reject(reason: impl Into<String>) -> Self {
262 Self::Reject {
263 reason: reason.into(),
264 }
265 }
266}
267
268#[derive(Clone, Copy, Debug, Eq, PartialEq)]
270pub enum HookVerdict {
271 FollowPolicy,
273 Approve,
275 Review,
277 Reject,
279}
280
281#[derive(Clone, Debug, Eq, PartialEq)]
283pub struct HookDecisionRecord {
284 pub event_id: RequestEventId,
286 pub hook_id: HookId,
288 pub verdict: HookVerdict,
290 pub reason: String,
292 pub principal: Option<PrincipalId>,
294}
295
296#[derive(Clone, Debug)]
298pub enum CommitPreparation {
299 #[doc(hidden)]
300 Ready {
301 transaction: TransactionId,
302 state: TransactionState,
303 },
304 Review(Box<RequestEvent>),
306}
307
308impl CommitPreparation {
309 #[must_use]
311 pub const fn transaction(&self) -> TransactionId {
312 match self {
313 Self::Ready { transaction, .. } => *transaction,
314 Self::Review(event) => event.transaction,
315 }
316 }
317
318 #[must_use]
320 pub fn event(&self) -> Option<&RequestEvent> {
321 match self {
322 Self::Ready { .. } => None,
323 Self::Review(event) => Some(event.as_ref()),
324 }
325 }
326
327 pub(crate) const fn prepared_state(&self) -> TransactionState {
328 match self {
329 Self::Ready { state, .. } => *state,
330 Self::Review(event) => event.state,
331 }
332 }
333}
334
335#[derive(Clone, Debug)]
337pub struct CommitResolution {
338 pub receipt: Receipt,
340 pub hook: Option<HookDecisionRecord>,
342}
343
344#[derive(Clone, Debug, Eq, PartialEq)]
346pub struct HookHandlerError {
347 detail: String,
348}
349
350impl HookHandlerError {
351 #[must_use]
353 pub fn new(detail: impl Into<String>) -> Self {
354 Self {
355 detail: detail.into(),
356 }
357 }
358}
359
360impl fmt::Display for HookHandlerError {
361 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
362 formatter.write_str(&self.detail)
363 }
364}
365
366impl Error for HookHandlerError {}
367
368pub trait CommitHook: Send + Sync {
371 fn handle(&self, event: &RequestEvent) -> Result<HookDecision, HookHandlerError>;
378}
379
380impl<F> CommitHook for F
381where
382 F: Fn(&RequestEvent) -> Result<HookDecision, HookHandlerError> + Send + Sync,
383{
384 fn handle(&self, event: &RequestEvent) -> Result<HookDecision, HookHandlerError> {
385 self(event)
386 }
387}
388
389pub struct HookedRuntime<H> {
391 runtime: Runtime,
392 handler: H,
393}
394
395impl<H: CommitHook> HookedRuntime<H> {
396 pub fn open(config: RuntimeConfig, hook: HookConfig, handler: H) -> Result<Self, VshError> {
403 Ok(Self {
404 runtime: Runtime::open(config.with_commit_hook(hook))?,
405 handler,
406 })
407 }
408
409 pub fn run(&self, mut request: RunRequest<'_>, now_unix_ms: u64) -> Result<Receipt, VshError> {
416 let mode = request.mode;
417 request.mode = RunMode::Preview;
418 let receipt = self.runtime.preview(request)?;
419 if mode == RunMode::Auto
420 && matches!(
421 receipt.state,
422 TransactionState::AutoApproved | TransactionState::PendingApproval
423 )
424 {
425 return self
426 .commit(receipt.transaction, now_unix_ms)
427 .map(|resolution| resolution.receipt);
428 }
429 Ok(receipt)
430 }
431
432 pub fn preview(&self, request: RunRequest<'_>) -> Result<Receipt, VshError> {
438 self.runtime.preview(request)
439 }
440
441 pub fn commit(
448 &self,
449 transaction: TransactionId,
450 now_unix_ms: u64,
451 ) -> Result<CommitResolution, VshError> {
452 let preparation = self.runtime.prepare_commit(transaction)?;
453 let decision = match preparation.event() {
454 Some(event) => match self.handler.handle(event) {
455 Ok(decision) => decision,
456 Err(source) => {
457 self.runtime.fail_hook(&preparation)?;
458 return Err(VshError::HookHandler(source));
459 }
460 },
461 None => HookDecision::FollowPolicy,
462 };
463 self.runtime
464 .resolve_commit(&preparation, &decision, now_unix_ms)
465 }
466
467 pub fn approve(
473 &self,
474 transaction: TransactionId,
475 principal: PrincipalId,
476 issued_at_unix_ms: u64,
477 expires_at_unix_ms: u64,
478 ) -> Result<TransactionRecord, VshError> {
479 self.runtime.approve(
480 transaction,
481 principal,
482 issued_at_unix_ms,
483 expires_at_unix_ms,
484 )
485 }
486
487 pub fn discard_preview(&self, transaction: TransactionId) -> Result<bool, VshError> {
493 self.runtime.discard_preview(transaction)
494 }
495
496 pub fn recover(&self) -> Result<RecoveryReport, VshError> {
502 self.runtime.recover()
503 }
504
505 pub fn transaction(&self, transaction: TransactionId) -> Result<TransactionRecord, VshError> {
511 self.runtime.transaction(transaction)
512 }
513}