Skip to main content

vsh/
hook.rs

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/// Which policy-authorized commit candidates a hook may inspect.
18#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19pub enum HookScope {
20    /// Invoke the hook only for transactions that deterministic policy escalated.
21    #[default]
22    ReviewRequired,
23    /// Invoke the hook for every successful simulation, including read-only requests.
24    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/// Trusted, transaction-bound configuration for one commit hook.
47#[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    /// Build a hook configuration from a stable host-controlled label.
58    #[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    /// Select whether automatic approvals are also reviewed.
70    #[must_use]
71    pub const fn with_scope(mut self, scope: HookScope) -> Self {
72        self.scope = scope;
73        self
74    }
75
76    /// Set the bounded lifetime of approvals generated by this hook.
77    #[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    /// Bound the UTF-8 reason retained in a decision record.
84    #[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    /// Opt into bounded, immutable before/after and observed-read content evidence.
91    ///
92    /// Zero (the default) disables additional content capture and delivery.
93    #[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    /// Return the total content-byte limit for one hook event.
100    #[must_use]
101    pub const fn max_content_bytes(self) -> usize {
102        self.max_content_bytes
103    }
104
105    /// Return the opaque hook identity.
106    #[must_use]
107    pub const fn id(self) -> HookId {
108        self.id
109    }
110
111    /// Return the configured trigger scope.
112    #[must_use]
113    pub const fn scope(self) -> HookScope {
114        self.scope
115    }
116
117    /// Return the hook-generated approval lifetime.
118    #[must_use]
119    pub const fn approval_ttl_ms(self) -> u64 {
120        self.approval_ttl_ms
121    }
122
123    /// Return the maximum decision-reason size.
124    #[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/// Deterministic policy outcome that caused a hook event.
141#[derive(Clone, Copy, Debug, Eq, PartialEq)]
142pub enum HookBaseline {
143    /// Policy authorized the transaction without independent review.
144    AutoApproved,
145    /// Policy required an independent review.
146    ReviewRequired,
147}
148
149/// Hash-verified bytes for one transaction-owned path/content identity.
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct ReviewContent {
152    /// Workspace-relative path authorized for content reading.
153    pub path: VPath,
154    /// Immutable identity referenced by the canonical diff or an observed read.
155    pub blob: BlobId,
156    /// Complete bytes; partial blobs are never represented as complete evidence.
157    pub bytes: Vec<u8>,
158}
159
160/// Immutable, bounded evidence supplied to a commit hook.
161#[derive(Clone, Debug, Eq, PartialEq)]
162pub struct RequestEvent {
163    /// Event schema understood by the receiver.
164    pub schema_version: u16,
165    /// Stable delivery identity for idempotent handlers.
166    pub event_id: RequestEventId,
167    /// Stable configured hook identity.
168    pub hook_id: HookId,
169    /// Exact transaction that may be committed.
170    pub transaction: TransactionId,
171    /// State observed while preparing this event.
172    pub state: TransactionState,
173    /// Policy branch that caused the event.
174    pub baseline: HookBaseline,
175    /// Immutable base snapshot identity.
176    pub base_snapshot: SnapshotId,
177    /// Canonical final diff identity.
178    pub diff: DiffDigest,
179    /// Exact read dependency identity.
180    pub read_set: ReadSetDigest,
181    /// Exact write precondition identity.
182    pub write_set: WriteSetDigest,
183    /// Exact untrusted program identity.
184    pub program: ProgramDigest,
185    /// Exact deterministic policy identity.
186    pub policy: PolicyDigest,
187    /// Exact security-relevant runtime configuration identity.
188    pub runtime_config: RuntimeConfigDigest,
189    /// Optional intent identity already bound into the transaction.
190    pub intent_digest: Option<IntentDigest>,
191    /// Bounded raw intent when available in the artifact.
192    pub intent: Option<String>,
193    /// Policy profile used for the baseline decision.
194    pub policy_profile: PolicyProfile,
195    /// Exact policy thresholds used for the baseline decision.
196    pub policy_thresholds: PolicyThresholds,
197    /// Exact risk metrics evaluated by deterministic policy.
198    pub risk_metrics: RiskMetrics,
199    /// Stable, sorted risk flags.
200    pub risk_flags: Vec<RiskFlag>,
201    /// Complete path-ordered canonical diff used by commit.
202    pub canonical_diff: Vec<DiffEntry>,
203    /// Ordered operation-level effects, containing identities rather than file bytes.
204    pub effects: Vec<EffectEvent>,
205    /// Independent execution counters.
206    pub execution: ExecutionStats,
207    /// Whether raw review evidence survived the durable boundary.
208    pub evidence_complete: bool,
209    /// Whether any evidence was deliberately truncated.
210    pub evidence_truncated: bool,
211    /// Bounded, read-authorized content from the exact artifact, never live files.
212    pub contents: Vec<ReviewContent>,
213    /// Whether every canonical content side and observed content read was included.
214    ///
215    /// Separate from structural evidence completeness. False for missing/stamped,
216    /// protected, disabled, or over-budget content.
217    pub content_complete: bool,
218}
219
220/// Decision returned by a hook handler.
221#[derive(Clone, Debug, Eq, PartialEq)]
222pub enum HookDecision {
223    /// Preserve deterministic policy: auto approvals commit; reviews remain pending.
224    FollowPolicy,
225    /// Approve and commit this exact transaction using the configured hook principal.
226    Approve {
227        /// Bounded explanation retained with the resolution.
228        reason: String,
229    },
230    /// Keep the transaction pending and return actionable review feedback.
231    Review {
232        /// Bounded feedback for the caller performing the next review.
233        feedback: String,
234    },
235    /// Terminally reject this exact transaction.
236    Reject {
237        /// Bounded explanation retained with the resolution.
238        reason: String,
239    },
240}
241
242impl HookDecision {
243    /// Construct an approval with a bounded human-readable reason.
244    #[must_use]
245    pub fn approve(reason: impl Into<String>) -> Self {
246        Self::Approve {
247            reason: reason.into(),
248        }
249    }
250
251    /// Construct a review requirement with a bounded human-readable reason.
252    #[must_use]
253    pub fn review(feedback: impl Into<String>) -> Self {
254        Self::Review {
255            feedback: feedback.into(),
256        }
257    }
258
259    /// Construct a terminal rejection with a bounded human-readable reason.
260    #[must_use]
261    pub fn reject(reason: impl Into<String>) -> Self {
262        Self::Reject {
263            reason: reason.into(),
264        }
265    }
266}
267
268/// Stable normalized outcome retained next to a hook resolution.
269#[derive(Clone, Copy, Debug, Eq, PartialEq)]
270pub enum HookVerdict {
271    /// Deterministic policy was preserved.
272    FollowPolicy,
273    /// The exact transaction was approved by the handler.
274    Approve,
275    /// The transaction remains pending with handler feedback.
276    Review,
277    /// The exact transaction was terminally rejected by the handler.
278    Reject,
279}
280
281/// Provenance for the hook decision applied to one exact event.
282#[derive(Clone, Debug, Eq, PartialEq)]
283pub struct HookDecisionRecord {
284    /// Exact immutable event resolved by the handler.
285    pub event_id: RequestEventId,
286    /// Configured handler identity bound to the event.
287    pub hook_id: HookId,
288    /// Normalized handler outcome.
289    pub verdict: HookVerdict,
290    /// Bounded explanation or review feedback supplied by the handler.
291    pub reason: String,
292    /// Approval principal when and only when the verdict is `Approve`.
293    pub principal: Option<PrincipalId>,
294}
295
296/// Prepared commit evidence, revalidated by the runtime before resolution.
297#[derive(Clone, Debug)]
298pub enum CommitPreparation {
299    #[doc(hidden)]
300    Ready {
301        transaction: TransactionId,
302        state: TransactionState,
303    },
304    /// An external handler must decide the supplied immutable event.
305    Review(Box<RequestEvent>),
306}
307
308impl CommitPreparation {
309    /// Return the exact transaction represented by this preparation.
310    #[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    /// Return the request event when a handler must run.
319    #[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/// Result of resolving a prepared commit request.
336#[derive(Clone, Debug)]
337pub struct CommitResolution {
338    /// Current transaction receipt after applying the hook decision.
339    pub receipt: Receipt,
340    /// Hook provenance, or `None` when deterministic policy was followed directly.
341    pub hook: Option<HookDecisionRecord>,
342}
343
344/// Contained handler failure. Runtime resolution applies fail-closed semantics first.
345#[derive(Clone, Debug, Eq, PartialEq)]
346pub struct HookHandlerError {
347    detail: String,
348}
349
350impl HookHandlerError {
351    /// Construct a contained handler failure.
352    #[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
368/// Synchronous Rust hook. Async hosts should drive `prepare_commit` and
369/// `resolve_commit` around their own executor instead of blocking the runtime.
370pub trait CommitHook: Send + Sync {
371    /// Return a decision from immutable evidence without mutating runtime state.
372    ///
373    /// # Errors
374    ///
375    /// Returns [`HookHandlerError`] when the host-owned handler cannot produce a
376    /// trustworthy decision. The runtime then keeps the transaction pending.
377    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
389/// Native runtime plus a host-owned commit handler.
390pub struct HookedRuntime<H> {
391    runtime: Runtime,
392    handler: H,
393}
394
395impl<H: CommitHook> HookedRuntime<H> {
396    /// Open a runtime whose direct commit path cannot bypass this hook.
397    ///
398    /// # Errors
399    ///
400    /// Returns a typed runtime, store, snapshot, or recovery error when the
401    /// configured workspace cannot be opened safely.
402    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    /// Execute one transaction, invoking the handler only for auto mode candidates.
410    ///
411    /// # Errors
412    ///
413    /// Returns any simulation, handler, validation, or commit error produced while
414    /// processing the exact transaction.
415    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    /// Execute without invoking the hook or changing host files.
433    ///
434    /// # Errors
435    ///
436    /// Returns the same typed simulation failures as [`Runtime::preview`].
437    pub fn preview(&self, request: RunRequest<'_>) -> Result<Receipt, VshError> {
438        self.runtime.preview(request)
439    }
440
441    /// Prepare, invoke the handler without runtime locks, and resolve exactly once.
442    ///
443    /// # Errors
444    ///
445    /// Returns a typed preparation, handler, state-validation, approval, or commit
446    /// error. Handler failures leave automatic approvals pending.
447    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    /// Bind an independent approval grant to a pending transaction.
468    ///
469    /// # Errors
470    ///
471    /// Returns a typed binding, lifetime, store, or state-transition error.
472    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    /// Discard an uncommitted transaction artifact.
488    ///
489    /// # Errors
490    ///
491    /// Returns a typed store or artifact-retention error.
492    pub fn discard_preview(&self, transaction: TransactionId) -> Result<bool, VshError> {
493        self.runtime.discard_preview(transaction)
494    }
495
496    /// Recover interrupted commits found in durable state.
497    ///
498    /// # Errors
499    ///
500    /// Returns a typed recovery, host-filesystem, or store error.
501    pub fn recover(&self) -> Result<RecoveryReport, VshError> {
502        self.runtime.recover()
503    }
504
505    /// Load the durable record for one transaction.
506    ///
507    /// # Errors
508    ///
509    /// Returns a typed store error when the transaction is missing or corrupt.
510    pub fn transaction(&self, transaction: TransactionId) -> Result<TransactionRecord, VshError> {
511        self.runtime.transaction(transaction)
512    }
513}