From 0cd491d74e46e07c5ad2f4b5e6af4f90e5561588 Mon Sep 17 00:00:00 2001 From: Marcus Patman Date: Thu, 20 Aug 2026 15:44:55 -0600 Subject: [PATCH 1/3] chore: reconcile license to MIT, add CONTRIBUTING.md, CHANGELOG entry (DOC-007, R5) --- CHANGELOG.md | 25 ++++++++++ CONTRIBUTING.md | 123 ++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 18 +++---- README.md | 2 +- 4 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d378d9..b65794e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added +- `CONTRIBUTING.md` covering development setup, workspace layout and bounded + contexts, code style, testing expectations, ADR process, and the + security-sensitive areas that get extra review +- Tag-driven release automation: verification (fmt, clippy, tests, `cargo audit`), + four-target binary builds (`x86_64-unknown-linux-gnu`, + `x86_64-unknown-linux-musl`, `aarch64-apple-darwin`, `x86_64-apple-darwin`) + with SHA-256 sums, GitHub Release creation from the CHANGELOG section, and an + opt-in crates.io publish that walks the workspace in dependency order + +### Changed +- License declaration reconciled to **MIT**, matching `LICENSE` and the README + badge — `workspace.package.license` previously declared Apache-2.0 +- Workspace-internal dependencies now carry an explicit `version` alongside + `path`; without it `cargo publish` rejects every crate in the workspace + +### Fixed +- `cargo clippy --workspace --all-targets -- -D warnings` — the exact command the + CI lint step runs — failed on current stable with eight `collapsible_match` + errors in `sentinel-tui`. The TUI key handler now uses match guards. Behaviour + is unchanged: `a`, `s` and `r` keep being swallowed off the Plan tab via an + explicit no-op arm rather than falling through to the Goal-tab text input + ## [0.1.0] - 2026-05-26 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a415ab3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,123 @@ +# Contributing to Sentinel + +Thanks for your interest. This document covers what you need to get a change +merged. + +## Scope + +Sentinel executes privileged system administration actions under LLM direction. +Every change is evaluated against one question first: **does this widen what the +agent can do without an operator saying yes?** If it does, it needs an ADR and an +explicit security review in the PR description — see below. + +## Development Setup + +Requirements: + +- Rust **1.75** or newer (stable) +- `cargo clippy`, `cargo fmt` (`rustup component add clippy rustfmt`) +- `cargo audit` (`cargo install cargo-audit`) for dependency checks +- Docker, only if you are changing the image +- `musl-tools` if you are building the static Linux binary + +```bash +git clone https://github.com/marcuspat/Sentinel.git +cd Sentinel +cargo check --workspace --all-targets +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --all -- --check +``` + +## Before You Open a PR + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +cargo audit +``` + +The first three must pass; CI runs the same commands. `cargo audit` findings +should be resolved or explained in the PR. + +## Workspace Layout + +The workspace is eight crates with a unidirectional dependency graph — no +circular dependencies. Keep it that way. + +| Crate | Bounded context | +|---|---| +| `sentinel-core` | Domain types, capability traits, shared errors | +| `sentinel-exec` | Sandboxed command execution (rlimits, timeouts, output caps) | +| `sentinel-policy` | Deny-by-default policy evaluation, risk tiers, kill switch | +| `sentinel-capabilities` | Concrete system capabilities | +| `sentinel-agent-llm` | Investigate–Plan–Approve–Act reasoning loop, LLM backends | +| `sentinel-audit` | SHA-256 hash-chained audit log, verification, metrics | +| `sentinel-fleet` | mTLS controller/agent fleet management | +| `sentinel-tui` | Terminal UI and the `sentinel` binary | + +A new capability belongs in `sentinel-capabilities` and must be registered with a +risk tier. A capability with no risk tier is a bug. + +## Code Style + +- `rustfmt` defaults; `clippy` with `-D warnings`. No `#[allow(...)]` without a + comment explaining why. +- Errors: `thiserror` for library error enums, `anyhow` at the binary boundary. + Never `unwrap()` or `expect()` on a path reachable from LLM input or from an + operator-supplied config. +- No `unsafe` outside of the sandbox syscall layer, and any new `unsafe` block + carries a `// SAFETY:` comment. +- Public items get doc comments. Security-relevant invariants get them in prose, + not just in types. + +## Testing + +- Unit tests live in-crate; the workspace carries 382 of them and that number + should not go down. +- `mockall` for trait mocks, `tempfile` for filesystem tests, `wiremock` for HTTP, + `criterion` for benchmarks (`cargo bench`, HTML reports under `target/criterion`). +- Anything touching the policy engine, the command allowlist, the sandbox, the + audit chain, or the approval gate **needs a test that demonstrates the deny + path**, not just the allow path. +- New risk-tier routing or resource-guard entries need a test proving the guard + actually blocks. + +## Architecture Decisions + +Significant design changes get an ADR under `docs/adr/` (`ADR-013`, `ADR-014`, …) +following the existing format: context, decision, consequences. Reference the ADR +number in the PR description. + +## Pull Requests + +- One logical change per PR. Mechanical reformatting goes in its own commit. +- Update `CHANGELOG.md` under an `## [Unreleased]` heading using + [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) categories. +- Update `README.md` if you add or change a CLI command or a documented capability. +- CI must be green before review. + +## Security-Sensitive Changes + +Do not open a public PR for a vulnerability fix. Follow [SECURITY.md](SECURITY.md) +and report it privately first. + +These areas get extra scrutiny — explain your reasoning in the PR description and +expect questions: + +- the deny-by-default evaluator, risk tiers, or kill switch (`sentinel-policy`) +- the exact-match command allowlist or shell-free execution path (`sentinel-exec`) +- rlimit sandbox configuration, timeouts, or output caps +- the PID guard, signal allowlist, or path validation (`sentinel-capabilities`) +- the audit hash chain, its genesis constant, or the verifier (`sentinel-audit`) +- mTLS setup or certificate pinning (`sentinel-fleet`) +- the approval gate and capability-ID validation (`sentinel-agent-llm`) + +Loosening any of these defaults is a breaking change even if the types do not +change. + +## License + +By contributing you agree that your contributions are licensed under the MIT +License, matching [LICENSE](LICENSE). diff --git a/Cargo.toml b/Cargo.toml index 741c65a..11170c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ resolver = "2" [workspace.package] version = "0.1.0" edition = "2021" -license = "Apache-2.0" +license = "MIT" repository = "https://github.com/marcuspat/Sentinel" authors = ["Sentinel Contributors"] @@ -79,14 +79,14 @@ tempfile = "3.9" wiremock = "0.6" # Workspace crates -sentinel-core = { path = "sentinel-core" } -sentinel-exec = { path = "sentinel-exec" } -sentinel-policy = { path = "sentinel-policy" } -sentinel-capabilities = { path = "sentinel-capabilities" } -sentinel-agent-llm = { path = "sentinel-agent-llm" } -sentinel-audit = { path = "sentinel-audit" } -sentinel-tui = { path = "sentinel-tui" } -sentinel-fleet = { path = "sentinel-fleet" } +sentinel-core = { path = "sentinel-core", version = "0.1.0" } +sentinel-exec = { path = "sentinel-exec", version = "0.1.0" } +sentinel-policy = { path = "sentinel-policy", version = "0.1.0" } +sentinel-capabilities = { path = "sentinel-capabilities", version = "0.1.0" } +sentinel-agent-llm = { path = "sentinel-agent-llm", version = "0.1.0" } +sentinel-audit = { path = "sentinel-audit", version = "0.1.0" } +sentinel-tui = { path = "sentinel-tui", version = "0.1.0" } +sentinel-fleet = { path = "sentinel-fleet", version = "0.1.0" } [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 9f71427..9e8c3d1 100644 --- a/README.md +++ b/README.md @@ -119,4 +119,4 @@ See [docs/adr/](docs/adr/) for full Architecture Decision Records and ## License -Apache-2.0 +MIT — see [LICENSE](LICENSE). From 7078dcc38777573951e9666b3b30cbfe074e4f80 Mon Sep 17 00:00:00 2001 From: Marcus Patman Date: Thu, 20 Aug 2026 15:50:59 -0600 Subject: [PATCH 2/3] fix(tui): use match guards in key handler so clippy -D warnings passes --- sentinel-tui/src/event_handler.rs | 62 +++++++++++++------------------ 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/sentinel-tui/src/event_handler.rs b/sentinel-tui/src/event_handler.rs index 7804a23..3529194 100644 --- a/sentinel-tui/src/event_handler.rs +++ b/sentinel-tui/src/event_handler.rs @@ -106,51 +106,41 @@ fn handle_key(app: &mut App, key: KeyEvent) { } // ── Plan approval actions ───────────────────────────────────────── - KeyCode::Char('a') => { - if app.current_tab == Tab::Plan { - app.approve_all(); - } + KeyCode::Char('a') if app.current_tab == Tab::Plan => { + app.approve_all(); } - KeyCode::Char('s') => { + KeyCode::Char('s') if app.current_tab == Tab::Plan => { // Step-by-step approval: approve the currently selected step. - if app.current_tab == Tab::Plan { - let idx = app.plan_view.selected_index; - app.approve_step(idx); - } + let idx = app.plan_view.selected_index; + app.approve_step(idx); } - KeyCode::Char('r') => { - if app.current_tab == Tab::Plan { - app.reject_plan("Rejected by operator.".into()); - } + KeyCode::Char('r') if app.current_tab == Tab::Plan => { + app.reject_plan("Rejected by operator.".into()); } + // Off the Plan tab these three keys are swallowed rather than falling + // through to the Goal-tab text input below. Removing these arms would + // change behaviour: 'a', 's' and 'r' would start inserting characters. + KeyCode::Char('a') | KeyCode::Char('s') | KeyCode::Char('r') => {} // ── Goal input (only on Goal tab) ───────────────────────────────── - KeyCode::Enter => { - if app.current_tab == Tab::Goal { - // Use the host and dry_run stored in app state (set from CLI args - // in run_tui) rather than hardcoded defaults. - let host = app.host.clone(); - let dry_run = app.dry_run; - app.start_session(host, dry_run); - } + KeyCode::Enter if app.current_tab == Tab::Goal => { + // Use the host and dry_run stored in app state (set from CLI args + // in run_tui) rather than hardcoded defaults. + let host = app.host.clone(); + let dry_run = app.dry_run; + app.start_session(host, dry_run); } - KeyCode::Char(c) => { - if app.current_tab == Tab::Goal { - // Insert character at cursor position. - app.goal_input.insert(app.input_cursor, c); - app.input_cursor += 1; - } + KeyCode::Char(c) if app.current_tab == Tab::Goal => { + // Insert character at cursor position. + app.goal_input.insert(app.input_cursor, c); + app.input_cursor += 1; } - KeyCode::Backspace => { - if app.current_tab == Tab::Goal && app.input_cursor > 0 { - app.input_cursor -= 1; - app.goal_input.remove(app.input_cursor); - } + KeyCode::Backspace if app.current_tab == Tab::Goal && app.input_cursor > 0 => { + app.input_cursor -= 1; + app.goal_input.remove(app.input_cursor); } - KeyCode::Left => { - if app.input_cursor > 0 { - app.input_cursor -= 1; - } + KeyCode::Left if app.input_cursor > 0 => { + app.input_cursor -= 1; } KeyCode::Right if app.input_cursor < app.goal_input.len() => { app.input_cursor += 1; From 3d956149d62881cdd897a6f653dd9466747f3a22 Mon Sep 17 00:00:00 2001 From: Marcus Patman Date: Thu, 20 Aug 2026 15:52:39 -0600 Subject: [PATCH 3/3] ci: add tag-driven release workflow (R6) --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 204 ++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2970e67..92bccd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: ["main", "feat/**", "fix/**", "chore/**"] + branches: ["main", "feat/**", "fix/**", "chore/**", "docs/**"] pull_request: branches: ["main"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8c4f688 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,204 @@ +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Existing tag to (re-)release, e.g. v0.1.0" + required: true + publish_crates: + description: "Publish to crates.io (requires CARGO_REGISTRY_TOKEN). Leave false for a dry run." + type: boolean + default: false + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + verify: + name: Verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + + - name: cargo clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: cargo test + run: cargo test --workspace + + - name: cargo audit + uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + binaries: + name: ${{ matrix.target }} + runs-on: ${{ matrix.os }} + needs: verify + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + musl: true + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-apple-darwin + os: macos-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Install musl tooling + if: matrix.musl + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Build + run: cargo build --release --locked --target ${{ matrix.target }} --bin sentinel + + - name: Package + shell: bash + run: | + set -euo pipefail + mkdir -p dist + cp "target/${{ matrix.target }}/release/sentinel" "dist/sentinel-${{ matrix.target }}" + cd dist + shasum -a 256 "sentinel-${{ matrix.target }}" > "sentinel-${{ matrix.target }}.sha256" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: sentinel-${{ matrix.target }} + path: dist/ + + release: + name: GitHub Release + runs-on: ubuntu-latest + needs: binaries + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Extract release notes from CHANGELOG + id: notes + shell: bash + run: | + set -euo pipefail + TAG="${{ github.event.inputs.tag || github.ref_name }}" + VERSION="${TAG#v}" + # Pull the section for this version out of CHANGELOG.md. + awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { found=1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md > release-notes.md + if [ ! -s release-notes.md ]; then + echo "No CHANGELOG.md section for $VERSION; falling back to generated notes." >&2 + echo "generated=true" >> "$GITHUB_OUTPUT" + else + echo "generated=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + name: Sentinel ${{ github.event.inputs.tag || github.ref_name }} + body_path: ${{ steps.notes.outputs.generated == 'false' && 'release-notes.md' || '' }} + generate_release_notes: ${{ steps.notes.outputs.generated == 'true' }} + prerelease: ${{ contains(github.event.inputs.tag || github.ref_name, '-') }} + files: artifacts/* + + publish: + name: crates.io + runs-on: ubuntu-latest + needs: release + # Publishing is opt-in and manual only. A tag push builds binaries and cuts + # the GitHub Release; re-run this workflow via workflow_dispatch to dry-run + # or (with publish_crates=true) actually publish to crates.io. + if: github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Publish workspace crates in dependency order + shell: bash + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + DO_PUBLISH: ${{ github.event.inputs.publish_crates == 'true' }} + run: | + set -euo pipefail + # Order matters: each crate must already be on the registry before a + # dependent crate is published. + CRATES=( + sentinel-core + sentinel-exec + sentinel-policy + sentinel-capabilities + sentinel-audit + sentinel-agent-llm + sentinel-fleet + sentinel-tui + ) + if [ "$DO_PUBLISH" != "true" ]; then + echo "::notice::Dry run — re-run this workflow with publish_crates=true to publish." + for c in "${CRATES[@]}"; do + cargo publish --dry-run --locked -p "$c" --allow-dirty + done + exit 0 + fi + if [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then + echo "::error::CARGO_REGISTRY_TOKEN is not set." + exit 1 + fi + for c in "${CRATES[@]}"; do + echo "::group::publish $c" + cargo publish --locked -p "$c" + # Give the index time to propagate before the next crate resolves it. + sleep 45 + echo "::endgroup::" + done