diff --git a/.gitignore b/.gitignore index 2551513..7a020e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.local.* target/ +*.profraw # steplock runtime state — commit checklists, ignore everything else .steplock/sessions/ diff --git a/Architecture.md b/Architecture.md index 8aafe96..35d8ec3 100644 --- a/Architecture.md +++ b/Architecture.md @@ -1,4 +1,4 @@ -# +# Architecture ## Multi-agent Safety @@ -187,7 +187,7 @@ Or embed as a library and call `steplock::run()` before your own logic. | ------ | ---------------------------------- | --------------------------------------- | | Scope | Normalize hook events across tools | Gate actions behind stateful checklists | | State | Stateless | Stateful (disk-backed) | -| Config | None | `steplock.toml` | +| Config | None | `.steplock/checklists/*/config.toml` | | Layer | Core SDK | Built on top of polyhook | | WASM | Yes — detection + serde | No — pure host logic | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3d53307 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing + +## Setup + +Requires [Rust](https://rustup.rs) (stable toolchain). + +```sh +git clone https://github.com/polyhook/steplock.git +cd steplock +cargo build +cargo test +``` + +## Project layout + +``` +steplock/ +├── core/ ← Rust crate (library + binary) +│ └── src/ +│ ├── bin/main.rs ← CLI entry point +│ ├── lib.rs ← public re-exports +│ ├── run.rs ← gate logic (main entry point: run()) +│ ├── cel_eval.rs ← CEL expression evaluation +│ ├── config.rs ← config.toml parsing +│ ├── flow.rs ← Mermaid stateDiagram-v2 parsing +│ ├── state.rs ← session state (state.json) +│ ├── scripts.rs ← ack.sh / preview.sh generation +│ └── audit.rs ← audit.log append +├── schemas/ ← JSON schemas for config.toml and state.json +├── examples/ ← working example configurations +├── scripts/ ← reference shell scripts (ack.sh, preview.sh) +├── Architecture.md ← design decisions and data flow +├── Installation.md ← end-user installation guide +└── Roadmap.md ← planned features +``` + +## Running tests + +```sh +cd core +cargo test # all tests +cargo test --all-targets # includes binary tests +``` + +## Code coverage + +```sh +cd core +cargo llvm-cov --summary-only +``` + +## Linting + +```sh +cd core +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` + +## Checklist gates (this repo uses steplock) + +Every `git commit` triggers a 4-step DDD naming checklist. Every `git push` triggers a 6-step quality checklist. The hook binary must be installed and registered: + +```sh +cargo install --path core +``` + +Then register in `.claude/settings.json` (already present in this repo). When the hook blocks a command, run the `ack.sh` printed in the message, then retry. + +## Pull requests + +Open a draft PR against `main`. CI runs on every PR — `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`, and release builds on ubuntu + macos. diff --git a/Installation.md b/Installation.md index 6d6b648..7b1bd15 100644 --- a/Installation.md +++ b/Installation.md @@ -54,7 +54,7 @@ mv target/release/steplock /usr/local/bin/ ```sh # Cargo -cargo install steplock +cargo install steplock-core ``` --- diff --git a/core/src/bin/main.rs b/core/src/bin/main.rs index c8e0aff..95b4125 100644 --- a/core/src/bin/main.rs +++ b/core/src/bin/main.rs @@ -1,28 +1,75 @@ use std::env; +use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; use std::process; use steplock_core::{run, HookEvent, HookResponse}; -fn main() { - let repo_root = find_repo_root_from(&env::current_dir().unwrap()) - .unwrap_or_else(|| env::current_dir().unwrap()); +const VERSION: &str = env!("CARGO_PKG_VERSION"); - let ph_event = match polyhook::read() { - Ok(e) => e, - Err(e) => { - eprintln!("steplock: failed to read hook input: {e}"); - process::exit(2); +fn main() { + let args: Vec = env::args().skip(1).collect(); + match args.as_slice() { + [flag] if flag == "--help" || flag == "-h" => { + print_help(); } - }; + [flag] if flag == "--version" || flag == "-V" => { + println!("steplock {VERSION}"); + } + [cmd] if cmd == "init" => { + let cwd = env::current_dir().unwrap(); + if let Err(e) = cmd_init(&cwd) { + eprintln!("steplock init: {e}"); + process::exit(1); + } + } + [] => run_hook(), + _ => { + eprintln!("steplock: unknown arguments"); + eprintln!("Run 'steplock --help' for usage."); + process::exit(1); + } + } +} - let event = polyhook_to_hook_event(ph_event); +fn print_help() { + println!( + "steplock {VERSION} +Stateful quality gates for AI coding agent tool calls. + +USAGE: + steplock [COMMAND] + + With no arguments, reads a polyhook event from stdin and responds. + +COMMANDS: + init Create .steplock/checklists/ directory in the current repo + +OPTIONS: + -h, --help Print this help + -V, --version Print version + +DOCUMENTATION: + https://github.com/polyhook/steplock" + ); +} - let response = match run(&event, &repo_root) { - Ok(HookResponse::Approve) => polyhook::HookResponse::approve(), - Ok(HookResponse::Block { message }) => polyhook::HookResponse::block(&message), +fn cmd_init(root: &Path) -> std::io::Result<()> { + let checklists = root.join(".steplock/checklists"); + fs::create_dir_all(&checklists)?; + println!("steplock: created {}", checklists.display()); + Ok(()) +} + +fn run_hook() { + let repo_root = find_repo_root_from(&env::current_dir().unwrap()) + .unwrap_or_else(|| env::current_dir().unwrap()); + + let response = match run_app(std::io::stdin(), &repo_root) { + Ok(r) => r, Err(e) => { - eprintln!("steplock: error: {e}"); + eprintln!("{e}"); process::exit(2); } }; @@ -33,6 +80,26 @@ fn main() { } } +/// Parse the hook event from `reader`, run the gate, and return the polyhook response. +/// Returns `Err(message)` when input is unreadable or the gate engine fails. +fn run_app(mut reader: impl Read, repo_root: &Path) -> Result { + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|e| format!("steplock: failed to read hook input: {e}"))?; + + let ph_event = polyhook::parse::parse_event(&bytes) + .map_err(|e| format!("steplock: failed to read hook input: {e}"))?; + + let event = polyhook_to_hook_event(ph_event); + + match run(&event, repo_root) { + Ok(HookResponse::Approve) => Ok(polyhook::HookResponse::approve()), + Ok(HookResponse::Block { message }) => Ok(polyhook::HookResponse::block(&message)), + Err(e) => Err(format!("steplock: error: {e}")), + } +} + fn polyhook_to_hook_event(e: polyhook::HookEvent) -> HookEvent { HookEvent { event: e.event.to_string(), @@ -66,7 +133,7 @@ mod tests { use std::fs; use tempfile::TempDir; - fn make_claude_stdin(cmd: &str, session: &str) -> String { + fn claude_stdin(cmd: &str, session: &str) -> String { serde_json::json!({ "hook_event_name": "PreToolUse", "tool_name": "Bash", @@ -100,9 +167,34 @@ reset = "session" .unwrap(); } + #[test] + fn version_string_is_nonempty() { + assert!(!VERSION.is_empty()); + } + + #[test] + fn print_help_does_not_panic() { + print_help(); + } + + #[test] + fn cmd_init_creates_checklists_dir() { + let tmp = TempDir::new().unwrap(); + cmd_init(tmp.path()).unwrap(); + assert!(tmp.path().join(".steplock/checklists").is_dir()); + } + + #[test] + fn cmd_init_is_idempotent() { + let tmp = TempDir::new().unwrap(); + cmd_init(tmp.path()).unwrap(); + cmd_init(tmp.path()).unwrap(); + assert!(tmp.path().join(".steplock/checklists").is_dir()); + } + #[test] fn polyhook_event_maps_correctly() { - let stdin = make_claude_stdin("git push origin main", "s1"); + let stdin = claude_stdin("git push origin main", "s1"); let ph_event = polyhook::parse::parse_event(stdin.as_bytes()).unwrap(); let event = polyhook_to_hook_event(ph_event); assert_eq!(event.event, "tool:before"); @@ -116,25 +208,56 @@ reset = "session" } #[test] - fn approves_non_matching_command() { + fn run_app_approves_non_matching_command() { let tmp = TempDir::new().unwrap(); setup_checklist(tmp.path()); - let stdin = make_claude_stdin("ls -la", "s1"); - let ph_event = polyhook::parse::parse_event(stdin.as_bytes()).unwrap(); - let event = polyhook_to_hook_event(ph_event); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); + let stdin = claude_stdin("ls -la", "s1"); + let resp = run_app(stdin.as_bytes(), tmp.path()).unwrap(); + assert!(matches!(resp, polyhook::HookResponse::ApproveResponse(_))); } #[test] - fn blocks_matching_command() { + fn run_app_blocks_matching_command() { let tmp = TempDir::new().unwrap(); setup_checklist(tmp.path()); - let stdin = make_claude_stdin("git push origin main", "s1"); - let ph_event = polyhook::parse::parse_event(stdin.as_bytes()).unwrap(); - let event = polyhook_to_hook_event(ph_event); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Block { .. })); + let stdin = claude_stdin("git push origin main", "s1"); + let resp = run_app(stdin.as_bytes(), tmp.path()).unwrap(); + assert!(matches!(resp, polyhook::HookResponse::BlockResponse(_))); + } + + #[test] + fn run_app_error_on_invalid_input() { + let tmp = TempDir::new().unwrap(); + let err = run_app(b"not valid json".as_ref(), tmp.path()); + assert!(err.is_err()); + assert!(err + .unwrap_err() + .contains("steplock: failed to read hook input")); + } + + #[test] + fn run_app_error_on_invalid_cel_expression() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/bad-gate"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + r#"on_event = "tool:before" +on_tool = "bash" +match_input = "!!!invalid cel!!!" +reset = "session" +"#, + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + "stateDiagram-v2\n [*] --> check\n check --> [*]\n check: Check\n", + ) + .unwrap(); + let stdin = claude_stdin("anything", "s1"); + let err = run_app(stdin.as_bytes(), tmp.path()); + assert!(err.is_err()); + assert!(err.unwrap_err().contains("steplock: error:")); } #[test] @@ -154,4 +277,11 @@ reset = "session" let root = find_repo_root_from(&subdir).unwrap(); assert_eq!(root, tmp.path()); } + + #[test] + fn find_repo_root_returns_none_when_not_found() { + let tmp = TempDir::new().unwrap(); + let result = find_repo_root_from(tmp.path()); + assert!(result.is_none()); + } } diff --git a/core/src/cel_eval.rs b/core/src/cel_eval.rs index a2bd664..4903e21 100644 --- a/core/src/cel_eval.rs +++ b/core/src/cel_eval.rs @@ -245,7 +245,7 @@ mod tests { #[test] fn json_float_input_converts() { let mut input = HashMap::new(); - input.insert("val".to_owned(), json!(3.14f64)); + input.insert("val".to_owned(), json!(1.23f64)); let ev = HookEvent { event: "tool:before".to_owned(), tool: "bash".to_owned(), diff --git a/core/src/config.rs b/core/src/config.rs index a4f7e14..5469c4c 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1,24 +1,34 @@ use serde::{Deserialize, Serialize}; +/// When to reset checklist progress for a scope. #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum Reset { + /// Reset when the agent session ends (default). Progress persists across retries within the same session. #[default] Session, + /// Reset on every invocation — no state persisted. Always, } +/// Parsed representation of a checklist's `config.toml`. #[derive(Debug, Clone, Deserialize)] pub struct ChecklistConfig { + /// Polyhook event type that triggers this checklist (e.g. `"tool:before"`). pub on_event: String, + /// Normalized polyhook tool name to match (e.g. `"bash"`, `"write_file"`). pub on_tool: String, + /// Optional CEL expression evaluated against the hook event. Omit to match all invocations. pub match_input: Option, + /// When to reset checklist progress. Defaults to `Reset::Session`. #[serde(default)] pub reset: Reset, + /// If true, the first block message includes a hint to run `preview.sh`. #[serde(default)] pub allow_preview_request: bool, } +/// Parse a `config.toml` file from its string content. pub fn parse_config(path: &str, content: &str) -> crate::Result { toml::from_str(content).map_err(|e| crate::error::SteplockError::Toml { path: path.to_owned(), diff --git a/core/src/flow.rs b/core/src/flow.rs index 2177d5d..d9c47b0 100644 --- a/core/src/flow.rs +++ b/core/src/flow.rs @@ -4,20 +4,20 @@ use crate::error::{Result, SteplockError}; #[derive(Debug, Clone)] pub struct FlowGraph { - /// States reachable from [*] (the initial states). + /// States reachable from `[*]` (the initial states). pub initial: Vec, - /// Outgoing transitions per state. States that go to [*] map to vec!["[*]"]. + /// Outgoing transitions per state. States that go to `[*]` map to `vec!["[*]"]`. pub transitions: HashMap>, /// Human-readable label per state. pub labels: HashMap, - /// States with a transition to [*] (terminal states). + /// States with a transition to `[*]` (terminal states). pub terminal: HashSet, /// Topological order of non-pseudo states (for preview output). pub order: Vec, } impl FlowGraph { - /// Returns states that are not yet visited and not the pseudo [*] node. + /// Returns states that are not yet visited and not the pseudo `[*]` node. pub fn pending_after(&self, visited: &[String]) -> Vec { let visited_set: HashSet<&str> = visited.iter().map(|s| s.as_str()).collect(); self.order @@ -27,7 +27,7 @@ impl FlowGraph { .collect() } - /// Outgoing transitions from `state`, excluding [*]. + /// Outgoing transitions from `state`, excluding `[*]`. pub fn next_states(&self, state: &str) -> Vec { self.transitions .get(state) @@ -35,12 +35,14 @@ impl FlowGraph { .unwrap_or_default() } - /// True if `state` is a terminal state (transitions to [*]). + /// True if `state` is a terminal state (transitions to `[*]`). pub fn is_terminal(&self, state: &str) -> bool { self.terminal.contains(state) } } +/// Parse a Mermaid `stateDiagram-v2` diagram from its string content. +/// `path` is used only for error messages. pub fn parse_mmd(path: &str, content: &str) -> Result { let mut transitions: HashMap> = HashMap::new(); let mut labels: HashMap = HashMap::new(); diff --git a/core/src/state.rs b/core/src/state.rs index 2b3f0ee..fe8ac52 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -16,11 +16,13 @@ pub struct SessionState { } impl SessionState { + /// Returns true when `current_state` is `"[*]"` (the terminal pseudo-state). pub fn is_complete(&self) -> bool { self.current_state == "[*]" } } +/// Load and deserialize a `state.json` file. pub fn load_state(path: &Path) -> Result { let content = fs::read_to_string(path)?; Ok(serde_json::from_str(&content)?) diff --git a/packages/README.md b/packages/README.md index d0d0602..cd37408 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,12 +1,14 @@ ## Language Support -Ships as a standalone CLI binary (works with any language) and as a native library for each polyhook SDK language. +Ships as a standalone CLI binary (works with any language) and as a native library for polyhook SDK languages. -| Distribution | Usage | -| ------------ | ------------------------------------------ | -| CLI binary | Drop-in hook script, zero code required | -| Rust crate | `steplock` — embed in existing hook binary | -| TypeScript | `@steplock/sdk` — wrap your hook handler | -| Go | `github.com/polyhook/steplock` | -| Python | `steplock` | -| C# | `Steplock` | +| Distribution | Status | Usage | +| ------------ | --------- | ------------------------------------------ | +| CLI binary | Available | Drop-in hook script, zero code required | +| Rust crate | Available | `steplock-core` — embed in existing binary | +| TypeScript | Planned | `@steplock/sdk` | +| Go | Planned | `github.com/polyhook/steplock` | +| Python | Planned | `steplock` | +| C# | Planned | `Steplock` | + +See [Roadmap.md](../Roadmap.md) for timeline. diff --git a/schemas/session-state.schema.json b/schemas/session-state.schema.json index e2519b2..aa09f00 100644 --- a/schemas/session-state.schema.json +++ b/schemas/session-state.schema.json @@ -21,9 +21,8 @@ }, "transitions": { "type": "array", - "description": "Valid next state names from current_state. ack.sh validates $1 against this list.", - "items": { "type": "string" }, - "minItems": 1 + "description": "Valid next state names from current_state. ack.sh validates $1 against this list. Empty when checklist is complete.", + "items": { "type": "string" } }, "visited": { "type": "array",