Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/rust-workspace.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Rust Workspace Validation

on:
pull_request:
paths:
- 'src/**/*.rs'
- 'tests/**/*.rs'
- 'crates/**/*.rs'
- 'crates/**/Cargo.toml'
- 'Cargo.toml'
- 'Cargo.lock'
- '.github/workflows/rust-abi.yml'
- '.github/workflows/rust-workspace.yml'
push:
branches:
- main

permissions:
contents: read

jobs:
test-rust-workspace:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Rust Stable Toolchain
uses: dtolnay/rust-toolchain@stable

- name: Cache Cargo Dependencies
uses: Swatinem/rust-cache@v2

- name: Check workspace
run: cargo check --workspace

- name: Test workspace
run: cargo test --workspace --all-targets
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"

[workspace]
members = ["crates/sentinel-core"]
members = ["crates/sentinel-core", "crates/ghost-twin"]
resolver = "2"

[dependencies]
Expand Down
9 changes: 9 additions & 0 deletions crates/ghost-twin/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
name = "ghost-twin"
version = "0.1.0"
edition = "2021"
description = "Deterministic Ghost/Twin execution boundary for SentinelAI"
license = "Apache-2.0"

[dependencies]
sentinel-core = { path = "../sentinel-core" }
12 changes: 12 additions & 0 deletions crates/ghost-twin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# ghost-twin

Isolated deterministic execution boundary for SentinelAI.

```text
Ghost -> TradeIntent -> Sentinel risk boundary -> Twin paper/shadow fill
\\-> ExecutionGate -> live only when allow_live=true
```

The crate contains no key management or signing and performs no external network operations. A future Jito adapter should accept already-signed transactions at an outer integration boundary.

Live execution is disabled by default.
140 changes: 140 additions & 0 deletions crates/ghost-twin/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#![forbid(unsafe_code)]

use sentinel_core::{Decision, SentinelCore};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TradeIntent {
pub chain_id: u64,
pub max_slippage_bps: u16,
pub amount_units: u64,
pub live_requested: bool,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PaperFill {
pub filled: bool,
pub amount_units: u64,
pub slippage_bps: u16,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GateDecision {
Shadow,
Deny,
Live,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ExecutionGate {
pub allow_live: bool,
}

impl ExecutionGate {
pub const fn denied() -> Self {
Self { allow_live: false }
}

pub const fn live_enabled() -> Self {
Self { allow_live: true }
}

#[inline(always)]
pub const fn decide(&self, risk: Decision, live_requested: bool) -> GateDecision {
match risk {
Decision::Block => GateDecision::Deny,
Decision::Allow if live_requested && self.allow_live => GateDecision::Live,
Decision::Monitor => GateDecision::Shadow,
Decision::Allow => GateDecision::Shadow,
}
}
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TwinEngine {
pub gate: ExecutionGate,
}

impl TwinEngine {
pub const fn new(gate: ExecutionGate) -> Self {
Self { gate }
}

#[inline(always)]
pub fn evaluate(
&self,
core: &mut SentinelCore,
intent: TradeIntent,
payload: &[u8],
) -> (Decision, GateDecision, PaperFill) {
let risk = core.scan(payload);
let gate = self.gate.decide(risk, intent.live_requested);
let fill = match gate {
GateDecision::Deny => PaperFill {
filled: false,
amount_units: 0,
slippage_bps: 0,
},
GateDecision::Shadow | GateDecision::Live => PaperFill {
filled: true,
amount_units: intent.amount_units,
slippage_bps: intent.max_slippage_bps,
},
};
(risk, gate, fill)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn live_is_disabled_by_default() {
let engine = TwinEngine::new(ExecutionGate::denied());
let mut core = SentinelCore::new();
let intent = TradeIntent {
chain_id: 1,
max_slippage_bps: 50,
amount_units: 100,
live_requested: true,
};
let (_, gate, fill) = engine.evaluate(&mut core, intent, b"clean");
assert_eq!(gate, GateDecision::Shadow);
assert!(fill.filled);
}

#[test]
fn monitor_can_never_go_live() {
let engine = TwinEngine::new(ExecutionGate::live_enabled());
let mut core = SentinelCore::new();
core.observe(sentinel_core::Signal::Debugger, 0);
let intent = TradeIntent {
chain_id: 1,
max_slippage_bps: 50,
amount_units: 100,
live_requested: true,
};
let (risk, gate, fill) = engine.evaluate(&mut core, intent, b"clean");
assert_eq!(risk, Decision::Monitor);
assert_eq!(gate, GateDecision::Shadow);
assert!(fill.filled);
}

#[test]
fn breach_denies_even_when_live_enabled() {
let engine = TwinEngine::new(ExecutionGate::live_enabled());
let mut core = SentinelCore::new();
core.observe(sentinel_core::Signal::Tamper, 0);
core.observe(sentinel_core::Signal::Root, 0);
let intent = TradeIntent {
chain_id: 1,
max_slippage_bps: 50,
amount_units: 100,
live_requested: true,
};
let (risk, gate, fill) = engine.evaluate(&mut core, intent, b"clean");
assert_eq!(risk, Decision::Block);
assert_eq!(gate, GateDecision::Deny);
assert!(!fill.filled);
}
}
2 changes: 1 addition & 1 deletion crates/sentinel-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ impl SentinelCore {
self.decision()
}
#[inline(always)] fn scan_token(&mut self, data: &[u8], token: &[u8], signal: Signal) { if let Some(offset) = find_subslice(data, token) { self.observe(signal, offset); } }
#[inline(always)] pub const fn decision(&self) -> Decision { match State::from_raw(self.state.load(Ordering::Acquire)) { State::Monitor => Decision::Allow, State::Anomalous => Decision::Monitor, State::Breached => Decision::Block } }
#[inline(always)] pub fn decision(&self) -> Decision { match State::from_raw(self.state.load(Ordering::Acquire)) { State::Monitor => Decision::Allow, State::Anomalous => Decision::Monitor, State::Breached => Decision::Block } }
#[inline(always)] pub const fn event_count(&self) -> usize { self.ring.len() }
#[inline(always)] pub fn event(&self, index: usize) -> Option<Event> { self.ring.get(index) }
}
Expand Down
Loading