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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion Cargo.lock

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

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "postil-cli"
version = "0.2.1"
version = "0.3.0"
edition = "2024"
description = "Postil: a low-noise AI review gate. Silent on clean PRs, hard gate on real risk."
license = "Apache-2.0"
Expand All @@ -23,6 +23,7 @@ reqwest = { version = "0.13.4", default-features = false, features = ["json", "r
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
serde_yaml = "0.9.34"
sha2 = "0.11.0"
similar = "3.1.1"
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "process", "time"] }

Expand Down
17 changes: 16 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::envelope::Severity;
use crate::envelope::{Kind, Severity};

pub const DEFAULT_MODEL: &str = "deepseek/deepseek-v4-pro";
pub const DEFAULT_CASCADE: [&str; 3] = [
Expand Down Expand Up @@ -89,6 +89,8 @@ pub struct Config {
pub gate_fail_on: GateLevel,
/// Gate behavior on operational error. Default: fail closed.
pub gate_on_error: OnError,
/// Finding kinds that block the gate regardless of severity. Default: [HumanEscalation].
pub block_on_kinds: Vec<Kind>,
pub model: String,
pub cascade: Vec<String>,
pub api_base: String,
Expand Down Expand Up @@ -122,6 +124,7 @@ impl Default for Config {
on_clean: OnClean::Skip,
gate_fail_on: GateLevel::Severity(Severity::Error),
gate_on_error: OnError::Block,
block_on_kinds: vec![Kind::HumanEscalation],
model: DEFAULT_MODEL.to_string(),
cascade: DEFAULT_CASCADE.iter().map(|m| (*m).to_string()).collect(),
api_base: DEFAULT_API_BASE.to_string(),
Expand Down Expand Up @@ -169,6 +172,7 @@ pub struct ReviewSection {
pub struct GateSection {
pub fail_on: Option<String>,
pub on_error: Option<OnError>,
pub block_on_kinds: Option<Vec<String>>,
}

#[derive(Debug, Default, Deserialize, Serialize)]
Expand Down Expand Up @@ -305,6 +309,15 @@ impl Config {
if let Some(oe) = g.on_error {
self.gate_on_error = oe;
}
if let Some(kinds) = g.block_on_kinds {
let mut parsed_kinds = Vec::new();
for kind_name in kinds {
parsed_kinds.push(Kind::parse(&kind_name).with_context(|| {
format!("invalid gate.blockOnKinds entry {kind_name:?} (risk|humanEscalation|guardrail|uncertainty|contentPolicy)")
})?);
}
self.block_on_kinds = parsed_kinds;
}
}
if let Some(m) = f.model {
if let Some(n) = m.name {
Expand Down Expand Up @@ -463,6 +476,8 @@ review:

gate:
failOn: error # the postil/gate check fails at/above: info | warn | error | never
# blockOnKinds: # finding kinds that block regardless of severity (default: [humanEscalation])
# - humanEscalation
# onError: block # block (default, fail closed) | advisory — gate outcome when
# # the review itself errors (model outage). advisory keeps an
# # outage from freezing merges; the review check goes neutral, not green.
Expand Down
33 changes: 33 additions & 0 deletions src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ pub enum Kind {
}

impl Kind {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"risk" => Some(Kind::Risk),
"humanescalation" => Some(Kind::HumanEscalation),
"guardrail" => Some(Kind::Guardrail),
"uncertainty" => Some(Kind::Uncertainty),
"contentpolicy" => Some(Kind::ContentPolicy),
_ => None,
}
}

pub fn as_str(self) -> &'static str {
match self {
Kind::Risk => "risk",
Expand All @@ -71,6 +82,10 @@ pub struct Finding {
pub confidence: f64,
pub title: String,
pub body: String,
/// Stable, engine-generated finding ID for deduplication and approval tracking.
/// Hash of (head_sha, kind, normalized_path, normalized_line, normalized_title, duplicate_index).
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
}

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
Expand All @@ -92,6 +107,9 @@ pub struct Gate {
/// Severity at or above which the gate check fails. "never" disables the gate.
pub fail_on: String,
pub failing: bool,
/// Finding kinds that block the gate regardless of severity.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub block_on_kinds: Vec<String>,
}

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
Expand Down Expand Up @@ -184,6 +202,7 @@ pub fn fail_closed_finding(detail: &str) -> Finding {
"Postil could not obtain a valid, diff-grounded review from the configured \
model(s) and is failing closed rather than passing unreviewed code.\n\nDetail: {detail}"
),
id: None,
}
}

Expand Down Expand Up @@ -211,6 +230,7 @@ pub fn narrated_risk_finding(summary: &str) -> Finding {
Re-run the review; if the contradiction persists, inspect the areas the \
summary names and address them or record findings manually."
),
id: None,
}
}

Expand All @@ -228,6 +248,7 @@ pub fn provider_error_finding(detail: &str) -> Finding {
"Postil could not complete the model request and is failing closed rather \
than passing unreviewed code.\n\nDetail: {detail}"
),
id: None,
}
}

Expand All @@ -245,6 +266,7 @@ mod tests {
confidence: conf,
title: "t".into(),
body: "b".into(),
id: None,
}
}

Expand All @@ -270,6 +292,16 @@ mod tests {
assert_eq!(Severity::parse("nope"), None);
}

#[test]
fn kind_parse() {
assert_eq!(Kind::parse("risk"), Some(Kind::Risk));
assert_eq!(Kind::parse("humanescalation"), Some(Kind::HumanEscalation));
assert_eq!(Kind::parse("guardrail"), Some(Kind::Guardrail));
assert_eq!(Kind::parse("uncertainty"), Some(Kind::Uncertainty));
assert_eq!(Kind::parse("contentpolicy"), Some(Kind::ContentPolicy));
assert_eq!(Kind::parse("unknown"), None);
}

#[test]
fn envelope_serializes_camel_case() {
let env = Envelope {
Expand All @@ -283,6 +315,7 @@ mod tests {
gate: Gate {
fail_on: "error".into(),
failing: false,
block_on_kinds: vec![],
},
model_used: "m".into(),
usage: Usage::default(),
Expand Down
1 change: 1 addition & 0 deletions src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ mod tests {
confidence: conf,
title: "t".into(),
body: "b".into(),
id: None,
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/forge/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ mod tests {
confidence: 0.91,
title: "Unsanitized input reaches query".into(),
body: "user_input flows into exec_query.".into(),
id: None,
}
}

Expand Down Expand Up @@ -376,6 +377,7 @@ mod tests {
gate: crate::envelope::Gate {
fail_on: "error".into(),
failing: false,
block_on_kinds: vec![],
},
model_used: "m".into(),
usage: Default::default(),
Expand All @@ -401,6 +403,7 @@ mod tests {
gate: crate::envelope::Gate {
fail_on: "error".into(),
failing: false,
block_on_kinds: vec![],
},
model_used: "none (disabled by config)".into(),
usage: Default::default(),
Expand Down
4 changes: 4 additions & 0 deletions src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ fn into_review(raw: RawReview, model: &str, usage: Usage) -> ModelReview {
confidence: f.confidence.clamp(0.0, 1.0),
title,
body: f.body,
id: None,
}
})
.collect();
Expand Down Expand Up @@ -699,6 +700,7 @@ mod tests {
confidence: conf,
title: "t".into(),
body: "b".into(),
id: None,
}],
model_used: model.into(),
usage: Usage {
Expand All @@ -721,6 +723,7 @@ mod tests {
confidence: 0.99,
title: "solo".into(),
body: "b".into(),
id: None,
});
let merged = consensus_merge(vec![a, b]);
assert_eq!(merged.findings.len(), 1);
Expand Down Expand Up @@ -757,6 +760,7 @@ mod tests {
confidence: 0.9,
title: "t2".into(),
body: "b2".into(),
id: None,
});
let merged = consensus_merge(vec![a, b]);
assert!(merged.findings.is_empty());
Expand Down
2 changes: 2 additions & 0 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ mod tests {
confidence: 0.9,
title: "\x1b[2Jhijacked title".into(),
body: "line one\n\x1b[31mFAKE ALL CLEAR\x1b[0m\nline three".into(),
id: None,
};
let env = Envelope {
version: 1,
Expand All @@ -261,6 +262,7 @@ mod tests {
gate: Gate {
fail_on: "error".into(),
failing: false,
block_on_kinds: vec![],
},
model_used: "m".into(),
usage: Default::default(),
Expand Down
Loading
Loading