diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..66e9072 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,194 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship made available under + the License, as indicated by a copyright notice that is included in + or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other transformations + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean, as submitted to the Licensor for inclusion + in the Work by the copyright owner or by an individual or Legal Entity + authorized to submit on behalf of the copyright owner. For the purposes + of this definition, "submitted" means any form of electronic, verbal, + or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems, and issue tracking systems that are managed + by, or on behalf of, the Licensor for the purpose of discussing and + improving the Work, but excluding communication that is conspicuously + marked or designated in writing by the copyright owner as "Not a + Contribution." + + "Contributor" shall mean Licensor and any Legal Entity on behalf of + whom a Contribution has been received by the Licensor and included + within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent contributions + Licensable by such Contributor that are necessarily infringed by + their Contribution(s) alone or by the combination of their + Contribution(s) with the Work to which such Contribution(s) was + submitted. If You institute patent litigation against any entity + (including a cross-claim or counterclaim in a lawsuit) alleging that + the Work or any combination of the Work with other Contributions + constitutes patent or contributory patent infringement, then any + patent licenses granted to You under this License for that Work shall + terminate as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, You must include a readable copy of the + attribution notices contained within such NOTICE file, in + at least one of the following places: within a NOTICE text + file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with the + Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the License. + + You may add Your own license statement for Your modifications and + may provide additional grant of rights to use, reproduce, modify, + prepare Derivative Works of, publicize, perform, distribute, sell, + import, and otherwise transfer the Contribution, where such license + applies only to those Contributions. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or reproducing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or exemplary damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or all other + commercial damages or losses), even if such Contributor has been + advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may offer only + conditions that are (1) purely permissive, such as an extended version + of this License, or (2) impose additional restrictions not already + contained in this License, including, without limitation, any + additional grant of rights to use, reproduce, modify, prepare + Derivative Works of, publicize, perform, distribute, sell, import, + and otherwise transfer the Contribution, where such license applies + only to those Contributions. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format in question. + + Copyright 2025 Mehmet Acar + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..b60c752 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Mehmet Acar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index bfb5c3e..3aece87 100644 --- a/README.md +++ b/README.md @@ -1 +1,415 @@ -### LogicShell +# LogicShell + +> A TDD-engineered, AI-augmented OS execution framework — embeddable Rust library for safe, auditable, policy-driven command dispatch with optional local LLM assistance. + +[![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) + +--- + +## What is LogicShell? + +LogicShell is a **library-first** Rust framework that sits between a host application (or operator CLI) and the OS process layer. It provides: + +- **Process dispatcher** — spawn external programs with explicit argv, environment, cwd, and stdin modes; capture bounded stdout; propagate structured exit status. +- **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. +- **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. + +--- + +## Project status + +| Milestone | Phases | Status | +|:----------|:-------|:-------| +| **M1** — Dispatcher, config, CI | 1–5 | ✅ Complete | +| **M2** — Safety engine, audit, hooks | 6–7 | 🔄 Phase 6 complete, Phase 7 next | +| **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 + +--- + +## Architecture + +``` +Host application / CLI + │ + ▼ + LogicShell facade ──── AuditSink (NDJSON, append-only) + │ + ├─► HookRunner (pre_exec hooks, per-hook timeout) + │ + ├─► SafetyPolicyEngine [Phase 7] ─► AuditSink + │ + ├─► ProcessDispatcher (tokio::process, stdout cap) + │ + └─► LlmBridge [Phases 8-10] + │ + └─► OllamaLlmClient (HTTP, mockable trait) +``` + +**Crate layout:** + +``` +logicshell-core/ — dispatcher, config, safety, audit, hooks (no HTTP) +logicshell-llm/ — LLM context, prompt composer, Ollama client (behind `ollama` feature) +``` + +**Key design decisions:** +- `SafetyPolicyEngine` and `PromptComposer` are **sync/pure** — no async in hot policy paths. +- `LlmClient` is an **async trait** — I/O-bound inference uses Tokio. +- No LLM calls in default `cargo test` (all mocked or gated behind `#[ignore]`). + +--- + +## Prerequisites + +| Tool | Version | Purpose | +|:-----|:--------|:--------| +| Rust toolchain | 1.75+ (stable) | Build and test | +| cargo-tarpaulin | latest | Coverage reports | +| Ollama _(optional)_ | any | Local LLM features (Phases 8–10) | + +Install Rust via [rustup](https://rustup.rs/). The repository pins the toolchain: + +```bash +# toolchain is automatically selected from rust-toolchain.toml +rustup show +``` + +Install tarpaulin once: + +```bash +cargo install cargo-tarpaulin +``` + +--- + +## Setup + +```bash +git clone https://github.com/logicshell/logicshell +cd logicshell +cargo build --workspace +``` + +No environment variables, daemons, or network access are required for the core build. + +--- + +## Running tests & coverage + +```bash +# Format check +cargo fmt --check + +# Lint (warnings are errors) +cargo clippy --workspace --all-features -- -D warnings + +# Full test suite (no network, no Ollama) +cargo test --workspace + +# Coverage report (HTML + XML, threshold ≥ 90%) +cargo tarpaulin --workspace + +# Open coverage report +xdg-open target/coverage/tarpaulin-report.html # Linux +open target/coverage/tarpaulin-report.html # macOS +``` + +Tests that require a live Ollama daemon are marked `#[ignore]` and excluded from default CI: + +```bash +# Run Ollama-backed tests explicitly (requires local daemon) +cargo test --workspace -- --ignored +``` + +--- + +## Running the demo + +A runnable example exercises every implemented phase end-to-end: + +```bash +cargo run --example demo +``` + +Expected output: + +``` +[Phase 3–4: Config schema + discovery] config parsed + discovered OK +[Phase 5: Dispatcher] echo, nonzero exit, stdin, cwd, truncation, env OK +[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 + +✓ All features verified OK +``` + +--- + +## Configuration + +LogicShell reads **`.logicshell.toml`** using the following search order (first match wins): + +1. `LOGICSHELL_CONFIG` environment variable — must be an absolute path. +2. Walk up from the current working directory for `.logicshell.toml`. +3. `$XDG_CONFIG_HOME/logicshell/config.toml` (falls back to `~/.config/…`). +4. Built-in defaults — no file required. + +### Full configuration reference + +```toml +schema_version = 1 # forward-compatible migrations +safety_mode = "balanced" # strict | balanced | loose + +[llm] +enabled = false # master switch; no LLM calls when false +provider = "ollama" # ollama (MVP); openai/anthropic post-MVP +base_url = "http://127.0.0.1:11434" +model = "llama3" # required when enabled = true +timeout_secs = 60 +allow_remote = false # must be false in MVP + +[llm.invocation] +nl_session = false # explicit natural-language mode +assist_on_not_found = false # suggest on exit code 127 +max_context_chars = 8000 # combined prompt context cap + +[safety] +deny_prefixes = ["rm -rf /", "mkfs", "dd if="] +allow_prefixes = [] +high_risk_patterns = [ + "rm\\s+-[rf]*r", + "sudo\\s+", + "curl.*\\|\\s*bash", + "wget.*\\|\\s*sh", +] + +[audit] +enabled = true +path = "/var/log/logicshell-audit.log" # omit → OS temp dir + +[[hooks.pre_exec]] +command = ["notify-send", "dispatching"] +timeout_ms = 5000 # hook killed after this many ms + +[limits] +max_stdout_capture_bytes = 1048576 # 1 MiB +max_llm_payload_bytes = 256000 +``` + +Unknown keys are **rejected at parse time** — the framework fails fast with file path and line number. + +--- + +## Embedding LogicShell + +Add `logicshell-core` to `Cargo.toml`: + +```toml +[dependencies] +logicshell-core = { path = "path/to/logicshell-core" } # crates.io once published +tokio = { version = "1", features = ["full"] } +``` + +Minimal host integration: + +```rust +use logicshell_core::{ + LogicShell, + config::Config, + audit::{AuditRecord, AuditDecision}, +}; + +#[tokio::main] +async fn main() { + // Load config from .logicshell.toml (walk-up) or use defaults + let config = logicshell_core::discover(std::env::current_dir().unwrap().as_path()) + .unwrap_or_default(); + + let ls = LogicShell::with_config(config); + + // Dispatch a command; pre-exec hooks run automatically + let exit_code = ls.dispatch(&["git", "status"]).await.expect("dispatch failed"); + println!("exit: {exit_code}"); + + // Write a manual audit record (e.g. after a user-denied command) + let record = AuditRecord::new( + std::env::current_dir().unwrap().to_string_lossy(), + vec!["rm".into(), "-rf".into(), "/".into()], + AuditDecision::Deny, + ).with_note("blocked by operator"); + ls.audit(&record).expect("audit failed"); +} +``` + +--- + +## Audit log format + +Each dispatch writes one JSON line to the configured file: + +```json +{"timestamp_secs":1714000000,"cwd":"/home/user/project","argv":["git","push"],"decision":"allow"} +{"timestamp_secs":1714000001,"cwd":"/","argv":["rm","-rf","/"],"decision":"deny","note":"blocked by policy stub"} +``` + +Fields: + +| Field | Type | Description | +|:------|:-----|:------------| +| `timestamp_secs` | `u64` | Unix timestamp (seconds) | +| `cwd` | `string` | Working directory at dispatch time | +| `argv` | `string[]` | Full command argv | +| `decision` | `"allow"` \| `"deny"` \| `"confirm"` | Safety policy outcome | +| `note` | `string?` | Optional annotation (omitted when absent) | + +The file is opened with `O_APPEND`; records survive process restarts and are flushed on `AuditSink` drop. + +--- + +## Use cases + +### 1. Auditable DevOps scripts + +Wrap deployment pipelines in LogicShell to capture every spawned command with timestamps and decisions. Replay the NDJSON log for incident forensics. + +```toml +safety_mode = "strict" +[audit] +enabled = true +path = "/var/log/deploy-audit.log" +[[hooks.pre_exec]] +command = ["slack-notify", "deploying to prod"] +timeout_ms = 3000 +``` + +### 2. AI-assisted terminal (planned — Phase 8+) + +Enable `llm.invocation.assist_on_not_found = true` to have LogicShell query a local Ollama model when a command returns exit code 127. The suggested correction is presented for confirmation before running — never auto-executed. + +```toml +[llm] +enabled = true +model = "llama3" +[llm.invocation] +assist_on_not_found = true +``` + +### 3. Embedded safety layer in custom tools + +Link `logicshell-core` in a Rust CLI to enforce deny/allow lists before spawning any subprocess. Set `safety_mode = "strict"` to require explicit confirmation for all high-risk patterns. + +### 4. Pre-exec hook orchestration + +Run arbitrary scripts before every dispatch — health checks, secret injection, notification webhooks — with hard timeouts so a slow hook never hangs the pipeline. + +--- + +## 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. +- `PromptComposer` — pure, sync, templates via `include_str!`, enforces `max_context_chars`. +- `LlmClient` async trait + `LlmRequest` / `LlmResponse` types. +- Build without the `ollama` feature; no HTTP deps in `logicshell-core`. + +### Phase 9 — OllamaLlmClient + +- `OllamaLlmClient` behind the `ollama` feature flag using `reqwest`. +- Health probe (`GET /api/tags`) with graceful degradation matrix. +- Full mockito test suite; zero real network in default `cargo test`. + +### Phase 10 — LlmBridge + AI-safety integration + +- `LlmBridge` orchestrates context → composer → client → parser → safety. +- `ProposedCommand` with `source: CommandSource::AiGenerated` raises the risk floor. +- NL session mode, argv-only mode, and assist-on-127 mode. +- Graceful degradation when Ollama is unreachable. + +--- + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the TDD workflow, PR checklist, and LLM backend extension guide. + +**Quick start:** + +```bash +# All checks must pass before opening a PR +cargo fmt --check +cargo clippy --workspace --all-features -- -D warnings +cargo test --workspace +cargo tarpaulin --workspace # must remain ≥ 90% +``` + +**Commit style:** conventional commits (`feat:`, `fix:`, `phase N:`, `test:`, `docs:`). + +--- + +## Repository structure + +``` +logicshell/ +├── logicshell-core/ # Core library (no HTTP) +│ ├── src/ +│ │ ├── lib.rs # Public façade: LogicShell struct +│ │ ├── dispatcher.rs # Async process dispatcher (Phase 5) +│ │ ├── audit.rs # Append-only NDJSON audit sink (Phase 6) +│ │ ├── hooks.rs # Pre-exec hook runner (Phase 6) +│ │ ├── error.rs # LogicShellError enum (Phase 2) +│ │ └── config/ +│ │ ├── mod.rs # load() + validate() +│ │ ├── schema.rs # Serde config types (Phase 3) +│ │ └── discovery.rs # Config discovery (Phase 4) +│ ├── tests/ +│ │ ├── dispatcher_integration.rs +│ │ ├── hooks_audit_integration.rs +│ │ └── e2e.rs # Full-stack end-to-end tests +│ └── examples/ +│ └── demo.rs # Runnable feature demonstration +├── logicshell-llm/ # LLM bridge (Phases 8–10) +├── docs/ +│ ├── PLAN.md +│ ├── ARCHITECTURE.md +│ ├── TESTING_STRATEGY.md +│ ├── LOGICSHELL_OPERATIONS.md +│ ├── LogicShell Framework PRD.md +│ └── LogicShell LLM Module PRD.md +├── tarpaulin.toml # Coverage config (gate: ≥ 90%) +├── rust-toolchain.toml # Pinned stable channel +└── Cargo.toml # Workspace root +``` + +--- + +## License + +Licensed under either of + +- **MIT License** ([LICENSE-MIT](LICENSE-MIT)) +- **Apache License, Version 2.0** ([LICENSE-APACHE](LICENSE-APACHE)) + +at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project shall be dual-licensed as above, without any additional terms or conditions. diff --git a/logicshell-core/examples/demo.rs b/logicshell-core/examples/demo.rs new file mode 100644 index 0000000..bc9a375 --- /dev/null +++ b/logicshell-core/examples/demo.rs @@ -0,0 +1,254 @@ +// LogicShell feature demonstration — runs each implemented phase end-to-end. +// +// Usage: +// cargo run --example demo +// +// Exit code: 0 on success, non-zero on first feature failure. + +use std::fs; +use std::path::PathBuf; + +use logicshell_core::{ + audit::{AuditDecision, AuditRecord, AuditSink}, + config::{AuditConfig, Config, HookEntry, HooksConfig, LimitsConfig}, + dispatcher::{DispatchOptions, Dispatcher, StdinMode}, + hooks::HookRunner, + LogicShell, +}; +use tempfile::TempDir; + +#[tokio::main] +async fn main() { + let tmp = TempDir::new().expect("temp dir"); + let all_ok = true; + + // ── Phase 3–4: Config schema + discovery ───────────────────────────────── + + print!("[Phase 3–4: Config schema + discovery] "); + let toml = r#" +schema_version = 1 +safety_mode = "strict" +[audit] +enabled = true +[limits] +max_stdout_capture_bytes = 65536 +"#; + let cfg = logicshell_core::config::load(toml).expect("parse"); + assert_eq!(cfg.schema_version, 1); + assert_eq!(cfg.limits.max_stdout_capture_bytes, 65_536); + + let disc_dir = TempDir::new().expect("tmp"); + fs::write(disc_dir.path().join(".logicshell.toml"), toml).expect("write cfg"); + let discovered = logicshell_core::discover(disc_dir.path()).expect("discover"); + assert_eq!(discovered.schema_version, 1); + println!("config parsed + discovered OK"); + + // ── Phase 5: Dispatcher ─────────────────────────────────────────────────── + + print!("[Phase 5: Dispatcher] "); + let d = Dispatcher::with_capture_limit(4096); + + let out = d + .dispatch(DispatchOptions { + argv: vec!["echo".into(), "hello logicshell".into()], + ..Default::default() + }) + .await + .expect("echo"); + assert_eq!(out.exit_code, 0); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("hello logicshell"), "got: {stdout:?}"); + + let out2 = d + .dispatch(DispatchOptions { + argv: vec!["sh".into(), "-c".into(), "exit 42".into()], + ..Default::default() + }) + .await + .expect("exit 42"); + assert_eq!(out2.exit_code, 42); + + let out3 = d + .dispatch(DispatchOptions { + argv: vec!["cat".into()], + stdin: StdinMode::Piped(b"stdin_data\n".to_vec()), + ..Default::default() + }) + .await + .expect("cat stdin"); + assert_eq!(out3.stdout, b"stdin_data\n"); + + let out4 = d + .dispatch(DispatchOptions { + argv: vec!["pwd".into()], + cwd: Some(PathBuf::from("/tmp")), + ..Default::default() + }) + .await + .expect("pwd"); + let pwd = String::from_utf8_lossy(&out4.stdout); + assert!(pwd.trim().contains("tmp"), "pwd: {pwd:?}"); + + let tiny = Dispatcher::with_capture_limit(5); + let out5 = tiny + .dispatch(DispatchOptions { + argv: vec!["echo".into(), "0123456789".into()], + ..Default::default() + }) + .await + .expect("truncate"); + assert!(out5.stdout_truncated); + assert!(out5.stdout.len() <= 5); + + let out6 = d + .dispatch(DispatchOptions { + argv: vec!["sh".into(), "-c".into(), "echo $DEMO_VAR".into()], + env_extra: vec![("DEMO_VAR".into(), "injected".into())], + ..Default::default() + }) + .await + .expect("env"); + assert!(String::from_utf8_lossy(&out6.stdout).contains("injected")); + + println!("echo, nonzero exit, stdin, cwd, truncation, env OK"); + + // ── Phase 6: Pre-exec hooks ─────────────────────────────────────────────── + + print!("[Phase 6: HookRunner] "); + let marker = tmp.path().join("hook_marker"); + + let ok_hooks = HooksConfig { + pre_exec: vec![HookEntry { + command: vec![ + "sh".into(), + "-c".into(), + format!("touch {}", marker.display()), + ], + timeout_ms: 5_000, + }], + }; + HookRunner::new(&ok_hooks) + .run_pre_exec() + .await + .expect("hook"); + assert!(marker.exists(), "marker missing"); + + let bad_hooks = HooksConfig { + pre_exec: vec![HookEntry { + command: vec!["false".into()], + timeout_ms: 5_000, + }], + }; + assert!(HookRunner::new(&bad_hooks).run_pre_exec().await.is_err()); + + let slow_hooks = HooksConfig { + pre_exec: vec![HookEntry { + command: vec!["sleep".into(), "60".into()], + timeout_ms: 80, + }], + }; + let err = HookRunner::new(&slow_hooks) + .run_pre_exec() + .await + .unwrap_err() + .to_string(); + assert!(err.contains("timed out"), "err: {err}"); + + println!("success, nonzero exit, timeout OK"); + + // ── Phase 6: Audit sink ─────────────────────────────────────────────────── + + print!("[Phase 6: AuditSink] "); + let log_path = tmp.path().join("demo_audit.log"); + + let mut sink = AuditSink::open(&log_path).expect("open"); + for decision in [ + AuditDecision::Allow, + AuditDecision::Deny, + AuditDecision::Confirm, + ] { + sink.write(&AuditRecord::new("/demo", vec!["cmd".into()], decision)) + .expect("write"); + } + drop(sink); + + let content = fs::read_to_string(&log_path).expect("read audit"); + let lines: Vec<_> = content.lines().collect(); + assert_eq!(lines.len(), 3); + for line in &lines { + serde_json::from_str::(line).expect("JSON"); + } + + let disabled_cfg = AuditConfig { + enabled: false, + path: None, + }; + let mut disabled = AuditSink::from_config(&disabled_cfg).expect("disabled"); + assert!(!disabled.is_enabled()); + disabled + .write(&AuditRecord::new("/", vec![], AuditDecision::Deny)) + .expect("noop"); + + println!("3 NDJSON records, flush-on-drop, disabled no-op OK"); + + // ── Phase 6: LogicShell façade — full pipeline ──────────────────────────── + + print!("[Phase 6: LogicShell façade] "); + let audit_path = tmp.path().join("facade_audit.log"); + let hook_marker = tmp.path().join("facade_hook"); + + let mut facade_cfg = Config::default(); + facade_cfg.limits = LimitsConfig { + max_stdout_capture_bytes: 4096, + ..LimitsConfig::default() + }; + facade_cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + facade_cfg.hooks.pre_exec = vec![HookEntry { + command: vec![ + "sh".into(), + "-c".into(), + format!("touch {}", hook_marker.display()), + ], + timeout_ms: 5_000, + }]; + + let ls = LogicShell::with_config(facade_cfg); + let code = ls.dispatch(&["true"]).await.expect("dispatch true"); + assert_eq!(code, 0); + assert!(hook_marker.exists(), "hook marker missing"); + + let content = fs::read_to_string(&audit_path).expect("read facade audit"); + let v: serde_json::Value = serde_json::from_str(content.trim()).expect("JSON"); + assert_eq!(v["decision"], "allow"); + assert_eq!(v["argv"][0], "true"); + + let manual_record = AuditRecord::new("/manual", vec!["custom".into()], AuditDecision::Confirm) + .with_note("user said yes"); + ls.audit(&manual_record).expect("audit()"); + + let content2 = fs::read_to_string(&audit_path).expect("audit2"); + assert_eq!(content2.lines().count(), 2, "2 records in audit log"); + + let mut bad_facade_cfg = Config::default(); + bad_facade_cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + bad_facade_cfg.hooks.pre_exec = vec![HookEntry { + command: vec!["false".into()], + timeout_ms: 5_000, + }]; + let ls2 = LogicShell::with_config(bad_facade_cfg); + assert!( + ls2.dispatch(&["true"]).await.is_err(), + "failing hook should abort" + ); + + println!("hook ran, audit written, façade.audit() appended, failing hook aborted OK"); + + // ── Summary ─────────────────────────────────────────────────────────────── + + if all_ok { + println!("\n✓ All features verified OK"); + } else { + eprintln!("\n✗ One or more features failed"); + std::process::exit(1); + } +} diff --git a/logicshell-core/src/audit.rs b/logicshell-core/src/audit.rs new file mode 100644 index 0000000..5607154 --- /dev/null +++ b/logicshell-core/src/audit.rs @@ -0,0 +1,457 @@ +// Audit log sink — §10.2, NFR-07 +// +// Writes append-only NDJSON records to a configured file path. +// Each record contains timestamp, cwd, argv, safety decision, and an optional note. +// The internal BufWriter is flushed when AuditSink is dropped so records survive +// process termination without an explicit flush call. + +use std::fs::{File, OpenOptions}; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::{config::AuditConfig, LogicShellError, Result}; + +/// Safety policy outcome recorded in the audit log (§10.2, FR-30–33). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuditDecision { + /// Command was allowed by the safety policy. + Allow, + /// Command was denied by the safety policy. + Deny, + /// Command required (and received) user confirmation. + Confirm, +} + +/// A single record appended to the audit log (§10.2). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditRecord { + /// Unix timestamp (seconds) when the record was created. + pub timestamp_secs: u64, + /// Working directory at dispatch time. + pub cwd: String, + /// Command argv; `argv[0]` is the executable. + pub argv: Vec, + /// Safety policy decision for this invocation. + pub decision: AuditDecision, + /// Optional human-readable annotation (e.g. "user confirmed", "hook denied"). + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +impl AuditRecord { + /// Create a record with the current Unix timestamp. + pub fn new(cwd: impl Into, argv: Vec, decision: AuditDecision) -> Self { + Self { + timestamp_secs: unix_now(), + cwd: cwd.into(), + argv, + decision, + note: None, + } + } + + /// Attach a human-readable note to this record. + pub fn with_note(mut self, note: impl Into) -> Self { + self.note = Some(note.into()); + self + } +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Append-only audit log sink (NFR-07, §10.2). +/// +/// Each [`AuditSink::write`] call appends one JSON line terminated by `\n`. +/// The writer is flushed in [`Drop`] so that buffered records reach the OS +/// even if the caller never calls [`AuditSink::flush`] explicitly. +pub struct AuditSink { + writer: Option>, +} + +impl AuditSink { + /// Create a sink from [`AuditConfig`]. + /// + /// Returns [`AuditSink::disabled`] when `enabled = false`; otherwise opens + /// (or creates) the configured path, falling back to the OS temp directory. + pub fn from_config(config: &AuditConfig) -> Result { + if !config.enabled { + return Ok(Self::disabled()); + } + let path = config + .path + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(default_path); + Self::open(&path) + } + + /// Open (or create + append) a log file at `path`. + pub fn open(path: &Path) -> Result { + let file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(LogicShellError::Io)?; + Ok(Self { + writer: Some(BufWriter::new(file)), + }) + } + + /// A no-op sink for when audit logging is disabled. + pub fn disabled() -> Self { + Self { writer: None } + } + + /// Returns `true` when this sink will write records to a file. + pub fn is_enabled(&self) -> bool { + self.writer.is_some() + } + + /// Append `record` as a single JSON line (`\n`-terminated). + /// + /// Returns `Ok(())` on a disabled sink (no-op). + pub fn write(&mut self, record: &AuditRecord) -> Result<()> { + if let Some(ref mut w) = self.writer { + let line = serde_json::to_string(record) + .map_err(|e| LogicShellError::Audit(format!("serialization failed: {e}")))?; + writeln!(w, "{line}").map_err(LogicShellError::Io)?; + } + Ok(()) + } + + /// Flush buffered bytes to the OS. + /// + /// Returns `Ok(())` on a disabled sink. + pub fn flush(&mut self) -> Result<()> { + if let Some(ref mut w) = self.writer { + w.flush().map_err(LogicShellError::Io)?; + } + Ok(()) + } +} + +impl Drop for AuditSink { + fn drop(&mut self) { + let _ = self.flush(); + } +} + +fn default_path() -> PathBuf { + std::env::temp_dir().join(".logicshell-audit.log") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AuditConfig; + use tempfile::TempDir; + + fn tmp_path(dir: &TempDir, name: &str) -> PathBuf { + dir.path().join(name) + } + + fn fixed_record(decision: AuditDecision) -> AuditRecord { + AuditRecord { + timestamp_secs: 1_700_000_000, + cwd: "/home/user".to_string(), + argv: vec!["ls".to_string(), "-la".to_string()], + decision, + note: None, + } + } + + // ── AuditDecision ───────────────────────────────────────────────────────── + + #[test] + fn decision_variants_clone_debug_eq() { + let all = [ + AuditDecision::Allow, + AuditDecision::Deny, + AuditDecision::Confirm, + ]; + for v in &all { + let c = v.clone(); + assert_eq!(&c, v); + assert!(!format!("{v:?}").is_empty()); + } + } + + #[test] + fn decision_serializes_lowercase() { + assert_eq!( + serde_json::to_string(&AuditDecision::Allow).unwrap(), + "\"allow\"" + ); + assert_eq!( + serde_json::to_string(&AuditDecision::Deny).unwrap(), + "\"deny\"" + ); + assert_eq!( + serde_json::to_string(&AuditDecision::Confirm).unwrap(), + "\"confirm\"" + ); + } + + #[test] + fn decision_roundtrips_json() { + for d in [ + AuditDecision::Allow, + AuditDecision::Deny, + AuditDecision::Confirm, + ] { + let s = serde_json::to_string(&d).unwrap(); + let back: AuditDecision = serde_json::from_str(&s).unwrap(); + assert_eq!(back, d); + } + } + + // ── AuditRecord ────────────────────────────────────────────────────────── + + #[test] + fn record_new_captures_current_timestamp() { + let before = unix_now(); + let rec = AuditRecord::new("/tmp", vec!["ls".into()], AuditDecision::Allow); + let after = unix_now(); + assert!(rec.timestamp_secs >= before); + assert!(rec.timestamp_secs <= after); + } + + #[test] + fn record_with_note_sets_field() { + let rec = AuditRecord::new("/tmp", vec!["ls".into()], AuditDecision::Allow).with_note("ok"); + assert_eq!(rec.note.as_deref(), Some("ok")); + } + + #[test] + fn record_without_note_omits_field_in_json() { + let rec = fixed_record(AuditDecision::Allow); + let s = serde_json::to_string(&rec).unwrap(); + assert!(!s.contains("note")); + } + + #[test] + fn record_clone_is_equal() { + let rec = fixed_record(AuditDecision::Deny); + assert_eq!( + serde_json::to_string(&rec.clone()).unwrap(), + serde_json::to_string(&rec).unwrap() + ); + } + + // ── AuditSink: disabled ────────────────────────────────────────────────── + + #[test] + fn disabled_sink_is_not_enabled() { + assert!(!AuditSink::disabled().is_enabled()); + } + + #[test] + fn disabled_write_is_noop_and_ok() { + let mut sink = AuditSink::disabled(); + assert!(sink.write(&fixed_record(AuditDecision::Allow)).is_ok()); + } + + #[test] + fn disabled_flush_is_noop_and_ok() { + let mut sink = AuditSink::disabled(); + assert!(sink.flush().is_ok()); + } + + // ── AuditSink: open ─────────────────────────────────────────────────────── + + #[test] + fn open_creates_file_and_is_enabled() { + let tmp = TempDir::new().unwrap(); + let path = tmp_path(&tmp, "audit.log"); + let sink = AuditSink::open(&path).unwrap(); + assert!(sink.is_enabled()); + } + + #[test] + fn open_nonexistent_dir_returns_io_error() { + let result = AuditSink::open(Path::new("/no/such/dir/audit.log")); + assert!(matches!(result, Err(LogicShellError::Io(_)))); + } + + // ── write + flush ───────────────────────────────────────────────────────── + + #[test] + fn write_and_flush_produces_json_line() { + let tmp = TempDir::new().unwrap(); + let path = tmp_path(&tmp, "audit.log"); + let mut sink = AuditSink::open(&path).unwrap(); + + sink.write(&fixed_record(AuditDecision::Allow)).unwrap(); + sink.flush().unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(!content.is_empty()); + let v: serde_json::Value = serde_json::from_str(content.trim()).unwrap(); + assert!(v.is_object()); + } + + #[test] + fn written_line_contains_all_expected_fields() { + let tmp = TempDir::new().unwrap(); + let path = tmp_path(&tmp, "audit.log"); + let mut sink = AuditSink::open(&path).unwrap(); + + let rec = AuditRecord { + timestamp_secs: 1_700_000_000, + cwd: "/home/user".to_string(), + argv: vec!["rm".to_string(), "-rf".to_string(), "/tmp/x".to_string()], + decision: AuditDecision::Deny, + note: Some("denied by policy".to_string()), + }; + sink.write(&rec).unwrap(); + sink.flush().unwrap(); + + let line = std::fs::read_to_string(&path).unwrap(); + let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); + + assert_eq!(v["timestamp_secs"], 1_700_000_000u64); + assert_eq!(v["cwd"], "/home/user"); + assert_eq!(v["decision"], "deny"); + assert_eq!(v["note"], "denied by policy"); + assert_eq!(v["argv"][0], "rm"); + } + + #[test] + fn multiple_writes_produce_one_line_each() { + let tmp = TempDir::new().unwrap(); + let path = tmp_path(&tmp, "audit.log"); + let mut sink = AuditSink::open(&path).unwrap(); + + for decision in [ + AuditDecision::Allow, + AuditDecision::Deny, + AuditDecision::Confirm, + ] { + sink.write(&fixed_record(decision)).unwrap(); + } + sink.flush().unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 3); + for line in &lines { + let _: serde_json::Value = serde_json::from_str(line).unwrap(); + } + } + + // ── append semantics ────────────────────────────────────────────────────── + + #[test] + fn reopening_the_file_appends_rather_than_truncates() { + let tmp = TempDir::new().unwrap(); + let path = tmp_path(&tmp, "audit.log"); + + { + let mut sink = AuditSink::open(&path).unwrap(); + sink.write(&fixed_record(AuditDecision::Allow)).unwrap(); + } // drop → flush + + { + let mut sink = AuditSink::open(&path).unwrap(); + sink.write(&fixed_record(AuditDecision::Deny)).unwrap(); + } // drop → flush + + let content = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + content.lines().count(), + 2, + "expected 2 lines after two reopen cycles" + ); + } + + // ── flush on drop ───────────────────────────────────────────────────────── + + #[test] + fn drop_flushes_buffered_bytes() { + let tmp = TempDir::new().unwrap(); + let path = tmp_path(&tmp, "audit.log"); + + { + let mut sink = AuditSink::open(&path).unwrap(); + sink.write(&fixed_record(AuditDecision::Confirm)).unwrap(); + // No explicit flush — Drop must flush. + } + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(!content.is_empty(), "Drop should have flushed the write"); + } + + // ── from_config ────────────────────────────────────────────────────────── + + #[test] + fn from_config_disabled_gives_disabled_sink() { + let cfg = AuditConfig { + enabled: false, + path: None, + }; + let sink = AuditSink::from_config(&cfg).unwrap(); + assert!(!sink.is_enabled()); + } + + #[test] + fn from_config_enabled_with_explicit_path() { + let tmp = TempDir::new().unwrap(); + let path = tmp_path(&tmp, "audit.log").to_str().unwrap().to_string(); + let cfg = AuditConfig { + enabled: true, + path: Some(path.clone()), + }; + let sink = AuditSink::from_config(&cfg).unwrap(); + assert!(sink.is_enabled()); + drop(sink); + assert!(Path::new(&path).exists()); + } + + #[test] + fn from_config_enabled_no_path_uses_default() { + let cfg = AuditConfig { + enabled: true, + path: None, + }; + // Default path is under temp dir; just verify it succeeds. + let sink = AuditSink::from_config(&cfg).unwrap(); + assert!(sink.is_enabled()); + } + + // ── JSON roundtrip ──────────────────────────────────────────────────────── + + #[test] + fn record_json_roundtrip_with_note() { + let original = AuditRecord { + timestamp_secs: 9_999, + cwd: "/srv".to_string(), + argv: vec!["echo".to_string(), "hi".to_string()], + decision: AuditDecision::Confirm, + note: Some("user said yes".to_string()), + }; + let json = serde_json::to_string(&original).unwrap(); + let parsed: AuditRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.timestamp_secs, 9_999); + assert_eq!(parsed.cwd, "/srv"); + assert_eq!(parsed.decision, AuditDecision::Confirm); + assert_eq!(parsed.note.as_deref(), Some("user said yes")); + } + + #[test] + fn record_json_roundtrip_without_note() { + let original = fixed_record(AuditDecision::Allow); + let json = serde_json::to_string(&original).unwrap(); + let parsed: AuditRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.decision, AuditDecision::Allow); + assert!(parsed.note.is_none()); + } +} diff --git a/logicshell-core/src/config/discovery.rs b/logicshell-core/src/config/discovery.rs index f162558..23c1f8d 100644 --- a/logicshell-core/src/config/discovery.rs +++ b/logicshell-core/src/config/discovery.rs @@ -41,7 +41,10 @@ pub fn find_config_path(cwd: &Path) -> Result> { } /// Testable inner loader — accepts explicit env values instead of reading the process env. -pub(crate) fn find_and_load( +/// +/// Useful in integration and E2E tests that need deterministic env without mutating +/// the process environment. +pub fn find_and_load( logicshell_config: Option<&str>, cwd: &Path, xdg_config_home: Option<&str>, diff --git a/logicshell-core/src/hooks.rs b/logicshell-core/src/hooks.rs new file mode 100644 index 0000000..117fd54 --- /dev/null +++ b/logicshell-core/src/hooks.rs @@ -0,0 +1,226 @@ +// Pre-execution hook runner — §12.5, NFR-07 +// +// Runs each HookEntry in HooksConfig.pre_exec sequentially. +// If any hook exits non-zero or exceeds its timeout_ms, an error is returned +// and the remaining hooks are skipped (fail-fast semantics). + +use std::time::Duration; + +use tokio::process::Command; +use tokio::time::timeout; + +use crate::{config::HooksConfig, LogicShellError, Result}; + +/// Runs pre-exec hooks from a [`HooksConfig`] (§12.5). +/// +/// Hooks execute sequentially; the first failure stops the chain. +pub struct HookRunner<'a> { + config: &'a HooksConfig, +} + +impl<'a> HookRunner<'a> { + pub fn new(config: &'a HooksConfig) -> Self { + Self { config } + } + + /// Run all `pre_exec` hooks in order. + /// + /// Returns the first [`LogicShellError::Hook`] encountered, or `Ok(())` when + /// all hooks succeed. An empty `pre_exec` list is a no-op. + pub async fn run_pre_exec(&self) -> Result<()> { + for hook in &self.config.pre_exec { + if hook.command.is_empty() { + continue; + } + let dur = Duration::from_millis(hook.timeout_ms); + match timeout(dur, spawn_hook(&hook.command)).await { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(e), + Err(_elapsed) => { + return Err(LogicShellError::Hook(format!( + "hook '{}' timed out after {}ms", + hook.command[0], hook.timeout_ms + ))); + } + } + } + Ok(()) + } +} + +async fn spawn_hook(command: &[String]) -> Result<()> { + let mut cmd = Command::new(&command[0]); + if command.len() > 1 { + cmd.args(&command[1..]); + } + let status = cmd + .spawn() + .map_err(|e| LogicShellError::Hook(format!("hook spawn failed: {e}")))? + .wait() + .await + .map_err(|e| LogicShellError::Hook(format!("hook wait failed: {e}")))?; + + if status.success() { + Ok(()) + } else { + Err(LogicShellError::Hook(format!( + "hook '{}' exited with non-zero status: {:?}", + command[0], + status.code() + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{HookEntry, HooksConfig}; + + fn make_config(entries: Vec) -> HooksConfig { + HooksConfig { pre_exec: entries } + } + + fn entry(command: &[&str], timeout_ms: u64) -> HookEntry { + HookEntry { + command: command.iter().map(|s| s.to_string()).collect(), + timeout_ms, + } + } + + // ── no hooks ────────────────────────────────────────────────────────────── + + #[tokio::test] + async fn empty_hooks_returns_ok() { + let cfg = make_config(vec![]); + assert!(HookRunner::new(&cfg).run_pre_exec().await.is_ok()); + } + + // ── successful hooks ────────────────────────────────────────────────────── + + #[tokio::test] + async fn single_successful_hook_returns_ok() { + let cfg = make_config(vec![entry(&["true"], 5_000)]); + assert!(HookRunner::new(&cfg).run_pre_exec().await.is_ok()); + } + + #[tokio::test] + async fn multiple_successful_hooks_all_run() { + let cfg = make_config(vec![ + entry(&["true"], 5_000), + entry(&["true"], 5_000), + entry(&["true"], 5_000), + ]); + assert!(HookRunner::new(&cfg).run_pre_exec().await.is_ok()); + } + + // ── nonzero exit ────────────────────────────────────────────────────────── + + #[tokio::test] + async fn failing_hook_returns_hook_error() { + let cfg = make_config(vec![entry(&["false"], 5_000)]); + let result = HookRunner::new(&cfg).run_pre_exec().await; + assert!(matches!(result, Err(LogicShellError::Hook(_)))); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("non-zero"), "expected 'non-zero' in: {msg}"); + } + + #[tokio::test] + async fn first_failing_hook_stops_chain() { + // Second hook would also fail; we only get one error. + let cfg = make_config(vec![entry(&["false"], 5_000), entry(&["false"], 5_000)]); + let result = HookRunner::new(&cfg).run_pre_exec().await; + assert!(matches!(result, Err(LogicShellError::Hook(_)))); + } + + #[tokio::test] + async fn failing_hook_before_success_stops_chain() { + let cfg = make_config(vec![entry(&["false"], 5_000), entry(&["true"], 5_000)]); + let result = HookRunner::new(&cfg).run_pre_exec().await; + assert!(matches!(result, Err(LogicShellError::Hook(_)))); + } + + // ── timeout ─────────────────────────────────────────────────────────────── + + #[cfg(unix)] + #[tokio::test] + async fn hook_exceeding_timeout_returns_hook_error() { + // 10 s sleep, 50 ms timeout → guaranteed timeout. + let cfg = make_config(vec![entry(&["sleep", "10"], 50)]); + let result = HookRunner::new(&cfg).run_pre_exec().await; + assert!(matches!(result, Err(LogicShellError::Hook(_)))); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("timed out"), "expected 'timed out' in: {msg}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn hook_error_message_includes_timeout_ms() { + let cfg = make_config(vec![entry(&["sleep", "10"], 50)]); + let err = HookRunner::new(&cfg) + .run_pre_exec() + .await + .unwrap_err() + .to_string(); + assert!(err.contains("50"), "expected timeout ms '50' in: {err}"); + } + + // ── empty command entry ─────────────────────────────────────────────────── + + #[tokio::test] + async fn empty_command_entry_is_skipped() { + let cfg = make_config(vec![HookEntry { + command: vec![], + timeout_ms: 100, + }]); + assert!(HookRunner::new(&cfg).run_pre_exec().await.is_ok()); + } + + // ── spawn failure ───────────────────────────────────────────────────────── + + #[tokio::test] + async fn nonexistent_executable_returns_hook_error() { + let cfg = make_config(vec![entry(&["__no_such_binary_xyz__"], 5_000)]); + let result = HookRunner::new(&cfg).run_pre_exec().await; + assert!(matches!(result, Err(LogicShellError::Hook(_)))); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("spawn failed"), + "expected 'spawn failed' in: {msg}" + ); + } + + // ── hook with args ──────────────────────────────────────────────────────── + + #[cfg(unix)] + #[tokio::test] + async fn hook_with_args_runs_correctly() { + // sh -c 'exit 0' → success + let cfg = make_config(vec![entry(&["sh", "-c", "exit 0"], 5_000)]); + assert!(HookRunner::new(&cfg).run_pre_exec().await.is_ok()); + } + + #[cfg(unix)] + #[tokio::test] + async fn hook_with_failing_args_returns_error() { + // sh -c 'exit 2' → nonzero + let cfg = make_config(vec![entry(&["sh", "-c", "exit 2"], 5_000)]); + let result = HookRunner::new(&cfg).run_pre_exec().await; + assert!(matches!(result, Err(LogicShellError::Hook(_)))); + } + + // ── hook name in error message ──────────────────────────────────────────── + + #[tokio::test] + async fn error_message_contains_hook_name() { + let cfg = make_config(vec![entry(&["false"], 5_000)]); + let err = HookRunner::new(&cfg) + .run_pre_exec() + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("false"), + "expected hook name 'false' in: {err}" + ); + } +} diff --git a/logicshell-core/src/lib.rs b/logicshell-core/src/lib.rs index d3e5188..a493386 100644 --- a/logicshell-core/src/lib.rs +++ b/logicshell-core/src/lib.rs @@ -1,13 +1,18 @@ // logicshell-core: dispatcher, config, safety, audit, hooks — no HTTP +pub mod audit; pub mod config; pub mod dispatcher; pub mod error; +pub mod hooks; + +pub use audit::{AuditDecision, AuditRecord, AuditSink}; pub use config::discovery::{discover, find_config_path}; pub use error::{LogicShellError, Result}; use config::Config; use dispatcher::{DispatchOptions, Dispatcher}; +use hooks::HookRunner; /// Top-level façade that coordinates configuration, safety, dispatch, and audit. /// @@ -32,18 +37,42 @@ impl LogicShell { /// Spawn a child process by argv and return its exit code — FR-01–04. /// - /// Uses `limits.max_stdout_capture_bytes` from the active config (NFR-08). + /// Pipeline (Phase 6): pre-exec hooks → dispatch → audit record written. /// A nonzero exit code is returned as `Ok(n)`, not an error. pub async fn dispatch(&self, argv: &[&str]) -> Result { + // Phase 6: run pre-exec hooks before dispatch. + HookRunner::new(&self.config.hooks).run_pre_exec().await?; + let d = Dispatcher::new(&self.config.limits); let opts = DispatchOptions { argv: argv.iter().map(|s| s.to_string()).collect(), ..DispatchOptions::default() }; 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("?")); + let record = AuditRecord::new( + cwd, + argv.iter().map(|s| s.to_string()).collect(), + AuditDecision::Allow, // Safety policy wired in Phase 7. + ); + AuditSink::from_config(&self.config.audit)?.write(&record)?; + Ok(output.exit_code) } + /// Append an [`AuditRecord`] to the configured audit log — §10.2, NFR-07. + /// + /// Opens (or creates) the audit file on each call; use directly when the + /// caller needs to record a decision that happened outside of `dispatch` + /// (e.g. a denied command or a user confirmation). + pub fn audit(&self, record: &AuditRecord) -> Result<()> { + AuditSink::from_config(&self.config.audit)?.write(record) + } + /// Stub: evaluate a command through the safety policy engine. /// /// Full implementation: Phase 7 (Safety policy engine — FR-30–33). @@ -52,15 +81,6 @@ impl LogicShell { "not yet implemented (phase 7)".into(), )) } - - /// Stub: append a record to the audit log. - /// - /// Full implementation: Phase 6 (Audit log — §10.2, NFR-07). - pub fn audit(&self, _record: &str) -> Result<()> { - Err(LogicShellError::Audit( - "not yet implemented (phase 6)".into(), - )) - } } impl Default for LogicShell { @@ -72,6 +92,15 @@ impl Default for LogicShell { #[cfg(test)] mod tests { use super::*; + use tempfile::TempDir; + + fn ls_with_temp_audit() -> (LogicShell, TempDir) { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("audit.log").to_str().unwrap().to_string(); + let mut cfg = Config::default(); + cfg.audit.path = Some(path); + (LogicShell::with_config(cfg), tmp) + } /// Phase 1 smoke: workspace builds and core crate is reachable — NFR-09, NFR-10 #[test] @@ -99,8 +128,7 @@ mod tests { /// Phase 5: dispatch runs a real command and returns its exit code — FR-01 #[tokio::test] async fn dispatch_runs_real_command() { - let ls = LogicShell::new(); - // `true` always exits 0 on Unix + let (ls, _tmp) = ls_with_temp_audit(); let result = ls.dispatch(&["true"]).await; assert!(result.is_ok(), "dispatch returned Err: {result:?}"); assert_eq!(result.unwrap(), 0); @@ -109,13 +137,107 @@ mod tests { /// Phase 5: dispatch propagates nonzero exit — FR-03 #[tokio::test] async fn dispatch_propagates_nonzero_exit() { - let ls = LogicShell::new(); - // `false` always exits 1 on Unix + let (ls, _tmp) = ls_with_temp_audit(); let result = ls.dispatch(&["false"]).await; assert!(result.is_ok(), "expected Ok(1), got Err: {result:?}"); assert_eq!(result.unwrap(), 1); } + /// Phase 6: dispatch writes an audit record for every invocation. + #[tokio::test] + async fn dispatch_writes_audit_record() { + 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); + + ls.dispatch(&["true"]).await.unwrap(); + + let content = std::fs::read_to_string(&audit_path).unwrap(); + assert!( + !content.is_empty(), + "audit log should be non-empty after dispatch" + ); + let v: serde_json::Value = serde_json::from_str(content.trim()).unwrap(); + assert_eq!(v["decision"], "allow"); + assert_eq!(v["argv"][0], "true"); + } + + /// Phase 6: dispatch with pre-exec hooks runs the hooks first. + #[tokio::test] + async fn dispatch_runs_pre_exec_hooks() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let hook_marker = tmp.path().join("hook_ran"); + + let mut cfg = Config::default(); + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + cfg.hooks.pre_exec = vec![crate::config::HookEntry { + command: vec![ + "sh".to_string(), + "-c".to_string(), + format!("touch {}", hook_marker.display()), + ], + timeout_ms: 5_000, + }]; + + let ls = LogicShell::with_config(cfg); + ls.dispatch(&["true"]).await.unwrap(); + + assert!( + hook_marker.exists(), + "pre-exec hook should have created the marker file" + ); + } + + /// Phase 6: a failing pre-exec hook prevents dispatch. + #[tokio::test] + async fn failing_hook_aborts_dispatch() { + 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()); + cfg.hooks.pre_exec = vec![crate::config::HookEntry { + command: vec!["false".to_string()], + timeout_ms: 5_000, + }]; + + let ls = LogicShell::with_config(cfg); + let result = ls.dispatch(&["true"]).await; + assert!(matches!(result, Err(LogicShellError::Hook(_)))); + } + + /// Phase 6: audit() writes a record to the configured path. + #[test] + fn audit_writes_record_to_configured_path() { + 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 record = AuditRecord::new("/tmp", vec!["ls".to_string()], AuditDecision::Allow); + ls.audit(&record).unwrap(); + + let content = std::fs::read_to_string(&audit_path).unwrap(); + assert!(!content.is_empty()); + let v: serde_json::Value = serde_json::from_str(content.trim()).unwrap(); + assert_eq!(v["decision"], "allow"); + } + + /// Phase 6: audit() with disabled config is a no-op. + #[test] + fn audit_disabled_config_is_noop() { + let mut cfg = Config::default(); + cfg.audit.enabled = false; + + let ls = LogicShell::with_config(cfg); + let record = AuditRecord::new("/tmp", vec!["ls".to_string()], AuditDecision::Deny); + assert!(ls.audit(&record).is_ok()); + } + /// Stub safety returns a `Safety` error, not a panic — NFR-06 #[test] fn safety_stub_returns_error() { @@ -123,12 +245,4 @@ mod tests { let result = ls.evaluate_safety(&["ls"]); assert!(matches!(result, Err(LogicShellError::Safety(_)))); } - - /// Stub audit returns an `Audit` error, not a panic — NFR-06 - #[test] - fn audit_stub_returns_error() { - let ls = LogicShell::new(); - let result = ls.audit("test record"); - assert!(matches!(result, Err(LogicShellError::Audit(_)))); - } } diff --git a/logicshell-core/tests/e2e.rs b/logicshell-core/tests/e2e.rs new file mode 100644 index 0000000..8290df4 --- /dev/null +++ b/logicshell-core/tests/e2e.rs @@ -0,0 +1,567 @@ +// End-to-end tests for LogicShell — exercises the full stack from config file +// through discovery, dispatch, hooks, and audit without mocking internals. +// +// These tests intentionally use real files, real child processes, and real I/O. +// Traces: FR-01–04, §10.2, §12.5, NFR-06, NFR-07, NFR-08, LOGICSHELL_OPERATIONS.md + +use std::fs; +use tempfile::TempDir; + +use logicshell_core::{ + audit::{AuditDecision, AuditRecord, AuditSink}, + config::{discovery::find_and_load, load, Config, HookEntry, LimitsConfig}, + discover, find_config_path, LogicShell, +}; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn write_cfg(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf { + let p = dir.path().join(name); + fs::write(&p, content).unwrap(); + p +} + +fn read_audit_lines(path: &std::path::Path) -> Vec { + let content = fs::read_to_string(path).unwrap_or_default(); + content + .lines() + .filter(|l| !l.is_empty()) + .map(|l| serde_json::from_str(l).expect("audit line must be valid JSON")) + .collect() +} + +// ── Config discovery → dispatch pipeline ───────────────────────────────────── + +/// FR-11 / OPERATIONS: discover() finds a project-level .logicshell.toml and +/// the loaded config drives dispatch behaviour. +#[tokio::test] +async fn e2e_config_file_drives_dispatch() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + + let toml = format!( + r#" +schema_version = 1 +safety_mode = "balanced" +[audit] +enabled = true +path = "{}" +[limits] +max_stdout_capture_bytes = 1024 +"#, + audit_path.display() + ); + write_cfg(&tmp, ".logicshell.toml", &toml); + + let cfg = discover(tmp.path()).unwrap(); + assert_eq!(cfg.schema_version, 1); + assert_eq!(cfg.limits.max_stdout_capture_bytes, 1024); + + let ls = LogicShell::with_config(cfg); + let code = ls.dispatch(&["true"]).await.unwrap(); + assert_eq!(code, 0); + + let records = read_audit_lines(&audit_path); + assert_eq!(records.len(), 1, "exactly one audit record after dispatch"); + assert_eq!(records[0]["decision"], "allow"); + assert_eq!(records[0]["argv"][0], "true"); +} + +/// LOGICSHELL_CONFIG override wins over walk-up .logicshell.toml. +/// +/// Uses find_and_load() directly to avoid mutating the process environment +/// (which would race with parallel tests that also call discover()). +#[tokio::test] +async fn e2e_explicit_config_override_wins_over_walkup() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("env_audit.log"); + + // Walk-up file with schema_version = 10 (should be ignored). + write_cfg(&tmp, ".logicshell.toml", "schema_version = 10\n"); + + // Override file with schema_version = 2. + let override_file = write_cfg( + &tmp, + "override.toml", + &format!( + "schema_version = 2\n[audit]\nenabled = true\npath = \"{}\"\n", + audit_path.display() + ), + ); + + // Pass the override path directly — no env var mutation needed. + let cfg = find_and_load( + Some(override_file.to_str().unwrap()), + tmp.path(), + None, + None, + ) + .unwrap(); + + assert_eq!(cfg.schema_version, 2, "override file must win over walk-up"); + let ls = LogicShell::with_config(cfg); + ls.dispatch(&["true"]).await.unwrap(); + assert!( + audit_path.exists(), + "audit file should be written to override-configured path" + ); +} + +/// find_config_path() returns the resolved file path used for discovery. +#[test] +fn e2e_find_config_path_returns_dotfile_path() { + let tmp = TempDir::new().unwrap(); + write_cfg(&tmp, ".logicshell.toml", "schema_version = 1\n"); + + if std::env::var("LOGICSHELL_CONFIG").is_err() { + let path = find_config_path(tmp.path()).unwrap(); + assert_eq!(path, Some(tmp.path().join(".logicshell.toml"))); + } +} + +// ── Sequential dispatch accumulates audit records ───────────────────────────── + +/// §10.2: every successful dispatch appends one audit record; multiple calls +/// accumulate in order without overwriting previous entries. +#[tokio::test] +async fn e2e_sequential_dispatch_accumulates_audit() { + 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); + + ls.dispatch(&["true"]).await.unwrap(); + ls.dispatch(&["sh", "-c", "exit 0"]).await.unwrap(); + ls.dispatch(&["false"]).await.unwrap(); // nonzero exit — still audited + + let records = read_audit_lines(&audit_path); + assert_eq!(records.len(), 3, "3 dispatches → 3 audit records"); + + // Each record references the right command + assert_eq!(records[0]["argv"][0], "true"); + assert_eq!(records[2]["argv"][0], "false"); + + // All decisions are "allow" (safety engine wired in Phase 7) + for r in &records { + assert_eq!(r["decision"], "allow"); + } +} + +/// §10.2: LogicShell::audit() appends records independently of dispatch. +#[test] +fn e2e_explicit_audit_call_appends_record() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("manual.log"); + + let mut cfg = Config::default(); + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + let ls = LogicShell::with_config(cfg); + + let deny_rec = AuditRecord::new( + "/secure", + vec!["rm".into(), "-rf".into(), "/".into()], + AuditDecision::Deny, + ) + .with_note("blocked by policy stub"); + ls.audit(&deny_rec).unwrap(); + + let confirm_rec = AuditRecord::new( + "/home/user", + vec!["sudo".into(), "apt".into()], + AuditDecision::Confirm, + ) + .with_note("user confirmed"); + ls.audit(&confirm_rec).unwrap(); + + let records = read_audit_lines(&audit_path); + assert_eq!(records.len(), 2); + assert_eq!(records[0]["decision"], "deny"); + assert_eq!(records[0]["note"], "blocked by policy stub"); + assert_eq!(records[1]["decision"], "confirm"); + assert_eq!(records[1]["argv"][1], "apt"); +} + +// ── Audit disabled mode ──────────────────────────────────────────────────────── + +/// When audit is disabled, dispatch succeeds without creating a log file. +#[tokio::test] +async fn e2e_dispatch_with_audit_disabled() { + let tmp = TempDir::new().unwrap(); + let would_be_audit = tmp.path().join("should_not_exist.log"); + + let mut cfg = Config::default(); + cfg.audit.enabled = false; + cfg.audit.path = Some(would_be_audit.to_str().unwrap().to_string()); + let ls = LogicShell::with_config(cfg); + + let code = ls.dispatch(&["true"]).await.unwrap(); + assert_eq!(code, 0); + assert!( + !would_be_audit.exists(), + "disabled audit must not create the log file" + ); +} + +// ── Exit-code propagation ───────────────────────────────────────────────────── + +/// FR-03: nonzero exit codes are propagated through the full LogicShell pipeline. +#[tokio::test] +async fn e2e_exit_code_propagation_through_facade() { + 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); + + for (cmd, expected) in [ + (vec!["true"], 0i32), + (vec!["false"], 1), + (vec!["sh", "-c", "exit 7"], 7), + (vec!["sh", "-c", "exit 127"], 127), + (vec!["sh", "-c", "exit 255"], 255), + ] { + let code = ls.dispatch(&cmd).await.unwrap(); + assert_eq!(code, expected, "cmd={cmd:?}"); + } + + // All five dispatches should be in the audit log + let records = read_audit_lines(&audit_path); + assert_eq!(records.len(), 5); +} + +// ── stdout capture limits ───────────────────────────────────────────────────── + +/// NFR-08: max_stdout_capture_bytes in config limits captured output. +#[tokio::test] +async fn e2e_stdout_cap_from_config() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + + let toml = format!( + "[audit]\nenabled=true\npath=\"{}\"\n[limits]\nmax_stdout_capture_bytes=10\n", + audit_path.display() + ); + let cfg = load(&toml).unwrap(); + let ls = LogicShell::with_config(cfg); + + // python-free way to emit more than 10 bytes + let code = ls + .dispatch(&["sh", "-c", "printf '%0.s.' {1..100}"]) + .await + .unwrap(); + assert_eq!(code, 0); + // We can't assert stdout here (facade doesn't expose it) but we can + // confirm the audit record is written — dispatcher didn't panic/error. + let records = read_audit_lines(&audit_path); + assert_eq!(records.len(), 1); +} + +// ── Hook + dispatch pipeline ────────────────────────────────────────────────── + +/// §12.5: a pre-exec hook defined in a config file runs before dispatch. +#[tokio::test] +async fn e2e_config_file_hook_runs_before_dispatch() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let hook_marker = tmp.path().join("hook_was_here"); + + let toml = format!( + r#" +[audit] +enabled = true +path = "{audit}" +[[hooks.pre_exec]] +command = ["sh", "-c", "touch {marker}"] +timeout_ms = 5000 +"#, + audit = audit_path.display(), + marker = hook_marker.display() + ); + let cfg = load(&toml).unwrap(); + let ls = LogicShell::with_config(cfg); + + let code = ls.dispatch(&["true"]).await.unwrap(); + assert_eq!(code, 0); + assert!(hook_marker.exists(), "hook must run before dispatch"); + + let records = read_audit_lines(&audit_path); + assert_eq!(records.len(), 1); +} + +/// §12.5: multiple hooks in config run in order. +#[tokio::test] +async fn e2e_multiple_hooks_run_in_order() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let first = tmp.path().join("1"); + let second = tmp.path().join("2"); + + let toml = format!( + r#" +[audit] +enabled = true +path = "{audit}" +[[hooks.pre_exec]] +command = ["sh", "-c", "touch {f}"] +timeout_ms = 5000 +[[hooks.pre_exec]] +command = ["sh", "-c", "touch {s}"] +timeout_ms = 5000 +"#, + audit = audit_path.display(), + f = first.display(), + s = second.display() + ); + let cfg = load(&toml).unwrap(); + let ls = LogicShell::with_config(cfg); + + ls.dispatch(&["true"]).await.unwrap(); + assert!(first.exists(), "first hook must run"); + assert!(second.exists(), "second hook must run"); +} + +/// §12.5: a failing hook aborts dispatch; no audit record is written. +#[tokio::test] +async fn e2e_failing_hook_aborts_dispatch_and_no_audit() { + 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()); + cfg.hooks.pre_exec = vec![HookEntry { + command: vec!["false".into()], + timeout_ms: 5_000, + }]; + let ls = LogicShell::with_config(cfg); + + let result = ls.dispatch(&["true"]).await; + assert!(result.is_err(), "dispatch must fail when hook fails"); + assert!( + !audit_path.exists(), + "no audit record should be written when hook aborts" + ); +} + +/// §12.5: a hook that exceeds its timeout aborts dispatch. +#[tokio::test] +async fn e2e_hook_timeout_aborts_dispatch() { + 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()); + cfg.hooks.pre_exec = vec![HookEntry { + command: vec!["sleep".into(), "60".into()], + timeout_ms: 80, + }]; + let ls = LogicShell::with_config(cfg); + + let result = ls.dispatch(&["true"]).await; + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("timed out"), + "error must mention timeout: {msg}" + ); + assert!(!audit_path.exists(), "no audit written after timeout abort"); +} + +// ── Audit log format guarantees ─────────────────────────────────────────────── + +/// §10.2: every audit record contains the required fields. +#[tokio::test] +async fn e2e_audit_record_has_required_fields() { + 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); + + ls.dispatch(&["sh", "-c", "exit 0"]).await.unwrap(); + + let records = read_audit_lines(&audit_path); + assert_eq!(records.len(), 1); + let rec = &records[0]; + + assert!( + rec["timestamp_secs"].is_number(), + "timestamp_secs must be present" + ); + assert!(rec["cwd"].is_string(), "cwd must be present"); + assert!(rec["argv"].is_array(), "argv must be present"); + assert!(rec["decision"].is_string(), "decision must be present"); + // timestamp must be a plausible Unix epoch (after 2020) + let ts = rec["timestamp_secs"].as_u64().unwrap(); + assert!(ts > 1_577_836_800, "timestamp looks unrealistic: {ts}"); +} + +/// §10.2: audit records survive close + reopen (append semantics). +#[tokio::test] +async fn e2e_audit_survives_reopen() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + + // First LogicShell instance writes one record. + { + let mut cfg = Config::default(); + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + LogicShell::with_config(cfg) + .dispatch(&["true"]) + .await + .unwrap(); + } + + // Second LogicShell instance opens the same file. + { + let mut cfg = Config::default(); + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + LogicShell::with_config(cfg) + .dispatch(&["false"]) + .await + .unwrap(); + } + + let records = read_audit_lines(&audit_path); + assert_eq!( + records.len(), + 2, + "both records must persist across instances" + ); + assert_eq!(records[0]["argv"][0], "true"); + assert_eq!(records[1]["argv"][0], "false"); +} + +// ── AuditSink standalone guarantees ────────────────────────────────────────── + +/// NFR-07: AuditSink::open on a bad path returns an I/O error, not a panic. +#[test] +fn e2e_audit_open_bad_path_returns_error() { + let result = AuditSink::open(std::path::Path::new("/no/such/dir/audit.log")); + assert!( + result.is_err(), + "opening an unreachable path must return Err" + ); +} + +/// NFR-07: writing to a disabled AuditSink is always a no-op. +#[test] +fn e2e_disabled_audit_sink_noop() { + let mut sink = AuditSink::disabled(); + for decision in [ + AuditDecision::Allow, + AuditDecision::Deny, + AuditDecision::Confirm, + ] { + let rec = AuditRecord::new("/", vec!["cmd".into()], decision); + assert!(sink.write(&rec).is_ok(), "disabled write must be Ok"); + } + assert!(sink.flush().is_ok(), "disabled flush must be Ok"); +} + +// ── Error handling / NFR-06 ─────────────────────────────────────────────────── + +/// NFR-06: dispatching a nonexistent binary returns a structured error, not a panic. +#[tokio::test] +async fn e2e_nonexistent_binary_returns_structured_error() { + 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(&["__no_such_binary_xyz__"]).await; + assert!(result.is_err(), "expected Err for nonexistent binary"); + // File must not exist: dispatch errored before audit write. + assert!(!audit_path.exists()); +} + +/// NFR-06: evaluate_safety stub returns Safety error, not a panic. +#[test] +fn e2e_safety_stub_error() { + let ls = LogicShell::new(); + let result = ls.evaluate_safety(&["rm", "-rf", "/"]); + assert!(result.is_err(), "safety stub must return Err until Phase 7"); +} + +// ── Config validation edge cases ────────────────────────────────────────────── + +/// §12.2: llm.enabled = true without model fails validation through the full stack. +#[test] +fn e2e_llm_enabled_without_model_fails_config_load() { + let result = load("[llm]\nenabled = true\n"); + assert!(result.is_err(), "missing model must fail validation"); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("llm.model"), + "error must name the missing field: {msg}" + ); +} + +/// §12.7: unknown keys in the config file produce a Config error. +#[test] +fn e2e_unknown_config_key_is_error() { + let result = load("completely_made_up_key = 42\n"); + assert!(result.is_err()); +} + +// ── Limits config via TOML ──────────────────────────────────────────────────── + +/// Config-driven stdout cap: parse a TOML file, create LogicShell, verify cap applied. +#[tokio::test] +async fn e2e_limits_from_config_file() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + + let toml = format!( + "[audit]\nenabled=true\npath=\"{}\"\n[limits]\nmax_stdout_capture_bytes=5\n", + audit_path.display() + ); + let cfg = load(&toml).unwrap(); + assert_eq!(cfg.limits.max_stdout_capture_bytes, 5); + + let ls = LogicShell::with_config(cfg); + // This just verifies the config path completes; actual truncation tested in dispatcher_integration. + let code = ls.dispatch(&["echo", "hi"]).await.unwrap(); + assert_eq!(code, 0); +} + +// ── LogicShell::new() default pipeline ─────────────────────────────────────── + +/// Default LogicShell (no config file) dispatches commands and writes to the +/// default temp-dir audit path without error. +#[tokio::test] +async fn e2e_default_logicshell_dispatch_succeeds() { + // Default config: audit enabled, path = OS temp dir + // We cannot predict the path, but we can verify dispatch doesn't error. + let ls = LogicShell::new(); + let code = ls.dispatch(&["true"]).await.unwrap(); + assert_eq!(code, 0); + + let code2 = ls.dispatch(&["false"]).await.unwrap(); + assert_eq!(code2, 1); +} + +// ── Custom LimitsConfig through facade ─────────────────────────────────────── + +/// FR-08: custom limits survive round-trip through Config and LogicShell. +#[tokio::test] +async fn e2e_custom_limits_round_trip() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + + let mut cfg = Config::default(); + cfg.limits = LimitsConfig { + max_stdout_capture_bytes: 256, + max_llm_payload_bytes: 1024, + }; + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + + assert_eq!(cfg.limits.max_stdout_capture_bytes, 256); + let ls = LogicShell::with_config(cfg); + ls.dispatch(&["true"]).await.unwrap(); + assert!(audit_path.exists()); +} diff --git a/logicshell-core/tests/hooks_audit_integration.rs b/logicshell-core/tests/hooks_audit_integration.rs new file mode 100644 index 0000000..e55ce3f --- /dev/null +++ b/logicshell-core/tests/hooks_audit_integration.rs @@ -0,0 +1,342 @@ +// Integration tests for Phase 6 — Pre-exec hooks & audit log +// Tests the full pipeline: hooks → dispatch → audit record written. +// Traces: §12.5, §10.2, NFR-07 + +use logicshell_core::{ + audit::{AuditDecision, AuditRecord, AuditSink}, + config::{AuditConfig, HookEntry, HooksConfig}, + error::LogicShellError, +}; +use tempfile::TempDir; + +// ── AuditSink integration ──────────────────────────────────────────────────── + +/// §10.2: audit line is parseable as NDJSON. +#[test] +fn audit_line_is_parseable_ndjson() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("audit.log"); + let mut sink = AuditSink::open(&path).unwrap(); + + let record = AuditRecord { + timestamp_secs: 1_700_000_042, + cwd: "/home/ops".to_string(), + argv: vec!["git".to_string(), "push".to_string()], + decision: AuditDecision::Allow, + note: None, + }; + sink.write(&record).unwrap(); + drop(sink); // flush on drop + + let line = std::fs::read_to_string(&path).unwrap(); + let line = line.trim(); + assert!(!line.is_empty(), "audit line should not be empty"); + assert!(line.starts_with('{'), "expected JSON object, got: {line:?}"); + assert!(line.ends_with('}'), "expected JSON object, got: {line:?}"); + + let v: serde_json::Value = serde_json::from_str(line).expect("audit line must be valid JSON"); + assert_eq!(v["timestamp_secs"], 1_700_000_042u64); + assert_eq!(v["cwd"], "/home/ops"); + assert_eq!(v["argv"][0], "git"); + assert_eq!(v["argv"][1], "push"); + assert_eq!(v["decision"], "allow"); +} + +/// NFR-07: multiple records are each on their own line and all parseable. +#[test] +fn multiple_audit_records_each_on_own_line() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("audit.log"); + let mut sink = AuditSink::open(&path).unwrap(); + + let decisions = [ + AuditDecision::Allow, + AuditDecision::Deny, + AuditDecision::Confirm, + ]; + for d in &decisions { + let rec = AuditRecord { + timestamp_secs: 0, + cwd: "/".to_string(), + argv: vec!["cmd".to_string()], + decision: d.clone(), + note: None, + }; + sink.write(&rec).unwrap(); + } + drop(sink); + + let content = std::fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 3, "expected 3 lines, one per record"); + for line in &lines { + serde_json::from_str::(line).expect("every line should be valid JSON"); + } +} + +/// NFR-07: flush on drop — bytes reach disk without explicit flush(). +#[test] +fn audit_flush_on_drop_integration() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("audit.log"); + + { + let mut sink = AuditSink::open(&path).unwrap(); + let rec = AuditRecord::new("/tmp", vec!["ls".into()], AuditDecision::Allow); + sink.write(&rec).unwrap(); + // No explicit flush — rely on Drop. + } + + let content = std::fs::read_to_string(&path).unwrap(); + assert!( + !content.is_empty(), + "Drop should have flushed the record to disk" + ); +} + +/// §10.2: audit records survive process crash simulation (close + reopen). +#[test] +fn audit_records_survive_reopen() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("audit.log"); + + // First "process" writes two records. + { + let mut sink = AuditSink::open(&path).unwrap(); + for d in [AuditDecision::Allow, AuditDecision::Deny] { + sink.write(&AuditRecord::new("/a", vec!["x".into()], d)) + .unwrap(); + } + } + + // Second "process" appends one more. + { + let mut sink = AuditSink::open(&path).unwrap(); + sink.write(&AuditRecord::new( + "/b", + vec!["y".into()], + AuditDecision::Confirm, + )) + .unwrap(); + } + + let content = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + content.lines().count(), + 3, + "all 3 records should persist across reopen" + ); +} + +/// from_config with path → writes to the specified path. +#[test] +fn from_config_with_path_writes_to_configured_location() { + let tmp = TempDir::new().unwrap(); + let explicit_path = tmp.path().join("explicit-audit.log"); + let cfg = AuditConfig { + enabled: true, + path: Some(explicit_path.to_str().unwrap().to_string()), + }; + + let mut sink = AuditSink::from_config(&cfg).unwrap(); + sink.write(&AuditRecord::new( + "/", + vec!["true".into()], + AuditDecision::Allow, + )) + .unwrap(); + drop(sink); + + assert!( + explicit_path.exists(), + "audit file should be created at the configured path" + ); + let content = std::fs::read_to_string(&explicit_path).unwrap(); + assert!(!content.is_empty()); +} + +/// from_config disabled → writes nothing, returns no error. +#[test] +fn from_config_disabled_writes_nothing() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("should-not-exist.log"); + let cfg = AuditConfig { + enabled: false, + path: Some(path.to_str().unwrap().to_string()), + }; + + let mut sink = AuditSink::from_config(&cfg).unwrap(); + sink.write(&AuditRecord::new( + "/", + vec!["ls".into()], + AuditDecision::Deny, + )) + .unwrap(); + drop(sink); + + // File should not have been created since auditing is disabled. + assert!( + !path.exists(), + "disabled audit sink should not create the log file" + ); +} + +// ── HookRunner integration ──────────────────────────────────────────────────── + +use logicshell_core::hooks::HookRunner; + +/// §12.5: hook timeout behavior — slow hook returns Hook error. +#[cfg(unix)] +#[tokio::test] +async fn hook_timeout_integration() { + let cfg = HooksConfig { + pre_exec: vec![HookEntry { + command: vec!["sleep".to_string(), "60".to_string()], + timeout_ms: 80, // intentionally short + }], + }; + + let result = HookRunner::new(&cfg).run_pre_exec().await; + assert!( + matches!(result, Err(LogicShellError::Hook(_))), + "expected Hook error from timeout, got: {result:?}" + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("timed out"), + "error should mention 'timed out': {msg}" + ); +} + +/// §12.5: successful hook chain → all hooks execute in order. +#[cfg(unix)] +#[tokio::test] +async fn successful_hook_chain_integration() { + let tmp = TempDir::new().unwrap(); + let first = tmp.path().join("first"); + let second = tmp.path().join("second"); + + let cfg = HooksConfig { + pre_exec: vec![ + HookEntry { + command: vec![ + "sh".to_string(), + "-c".to_string(), + format!("touch {}", first.display()), + ], + timeout_ms: 5_000, + }, + HookEntry { + command: vec![ + "sh".to_string(), + "-c".to_string(), + format!("touch {}", second.display()), + ], + timeout_ms: 5_000, + }, + ], + }; + + HookRunner::new(&cfg).run_pre_exec().await.unwrap(); + + assert!(first.exists(), "first hook should have created its marker"); + assert!( + second.exists(), + "second hook should have created its marker" + ); +} + +/// §12.5: failing hook stops execution before subsequent hooks run. +#[cfg(unix)] +#[tokio::test] +async fn failing_hook_stops_chain_integration() { + let tmp = TempDir::new().unwrap(); + let should_not_exist = tmp.path().join("should_not_exist"); + + let cfg = HooksConfig { + pre_exec: vec![ + HookEntry { + command: vec!["false".to_string()], + timeout_ms: 5_000, + }, + HookEntry { + command: vec![ + "sh".to_string(), + "-c".to_string(), + format!("touch {}", should_not_exist.display()), + ], + timeout_ms: 5_000, + }, + ], + }; + + let result = HookRunner::new(&cfg).run_pre_exec().await; + assert!(matches!(result, Err(LogicShellError::Hook(_)))); + assert!( + !should_not_exist.exists(), + "second hook must not run after first hook fails" + ); +} + +// ── End-to-end pipeline: hooks → dispatch → audit ──────────────────────────── + +use logicshell_core::LogicShell; + +/// Full Phase 6 pipeline: successful pre-exec hook, dispatch, audit record written. +#[cfg(unix)] +#[tokio::test] +async fn full_pipeline_hooks_dispatch_audit() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let hook_marker = tmp.path().join("hook_ran"); + + let mut cfg = logicshell_core::config::Config::default(); + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + cfg.hooks.pre_exec = vec![HookEntry { + command: vec![ + "sh".to_string(), + "-c".to_string(), + format!("touch {}", hook_marker.display()), + ], + timeout_ms: 5_000, + }]; + + let ls = LogicShell::with_config(cfg); + let exit_code = ls.dispatch(&["true"]).await.unwrap(); + + assert_eq!(exit_code, 0, "dispatch should return 0 for 'true'"); + assert!(hook_marker.exists(), "pre-exec hook should have run"); + + // Verify audit record is written and parseable. + let content = std::fs::read_to_string(&audit_path).unwrap(); + assert!(!content.is_empty(), "audit log should not be empty"); + let v: serde_json::Value = + serde_json::from_str(content.trim()).expect("audit line must be valid JSON"); + assert_eq!(v["decision"], "allow"); + assert_eq!(v["argv"][0], "true"); +} + +/// Failing hook in the pipeline returns Hook error; no audit record is written. +#[cfg(unix)] +#[tokio::test] +async fn failing_hook_aborts_pipeline_no_audit() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + + let mut cfg = logicshell_core::config::Config::default(); + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + cfg.hooks.pre_exec = vec![HookEntry { + command: vec!["false".to_string()], + timeout_ms: 5_000, + }]; + + let ls = LogicShell::with_config(cfg); + let result = ls.dispatch(&["true"]).await; + + assert!(matches!(result, Err(LogicShellError::Hook(_)))); + // Audit file should not exist (hooks failed before dispatch & audit). + assert!( + !audit_path.exists(), + "audit file should not be created when hooks fail" + ); +}