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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
*.local.*
target/
*.profraw

# steplock runtime state — commit checklists, ignore everything else
.steplock/sessions/
Expand Down
4 changes: 2 additions & 2 deletions Architecture.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#
# Architecture

## Multi-agent Safety

Expand Down Expand Up @@ -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 |

Expand Down
72 changes: 72 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion Installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ mv target/release/steplock /usr/local/bin/

```sh
# Cargo
cargo install steplock
cargo install steplock-core
```

---
Expand Down
186 changes: 158 additions & 28 deletions core/src/bin/main.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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);
}
};
Expand All @@ -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<polyhook::HookResponse, String> {
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(),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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");
Expand All @@ -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]
Expand All @@ -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());
}
}
2 changes: 1 addition & 1 deletion core/src/cel_eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
10 changes: 10 additions & 0 deletions core/src/config.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// 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<ChecklistConfig> {
toml::from_str(content).map_err(|e| crate::error::SteplockError::Toml {
path: path.to_owned(),
Expand Down
Loading
Loading