mirror of
https://github.com/instructkr/claude-code.git
synced 2026-04-05 18:58:48 +03:00
merge: ultraclaw/policy-engine into main
This commit is contained in:
@@ -14,6 +14,7 @@ mod mcp_stdio;
|
|||||||
pub mod mcp_tool_bridge;
|
pub mod mcp_tool_bridge;
|
||||||
mod oauth;
|
mod oauth;
|
||||||
pub mod permission_enforcer;
|
pub mod permission_enforcer;
|
||||||
|
mod policy_engine;
|
||||||
mod permissions;
|
mod permissions;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod remote;
|
mod remote;
|
||||||
@@ -78,6 +79,10 @@ pub use oauth::{
|
|||||||
OAuthCallbackParams, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet,
|
OAuthCallbackParams, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet,
|
||||||
PkceChallengeMethod, PkceCodePair,
|
PkceChallengeMethod, PkceCodePair,
|
||||||
};
|
};
|
||||||
|
pub use policy_engine::{
|
||||||
|
evaluate, DiffScope, GreenLevel, LaneBlocker, LaneContext, PolicyAction, PolicyCondition,
|
||||||
|
PolicyEngine, PolicyRule, ReviewStatus,
|
||||||
|
};
|
||||||
pub use permissions::{
|
pub use permissions::{
|
||||||
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
|
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
|
||||||
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
|
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
|
||||||
|
|||||||
458
rust/crates/runtime/src/policy_engine.rs
Normal file
458
rust/crates/runtime/src/policy_engine.rs
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub type GreenLevel = u8;
|
||||||
|
|
||||||
|
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_secs(60 * 60);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PolicyRule {
|
||||||
|
pub name: String,
|
||||||
|
pub condition: PolicyCondition,
|
||||||
|
pub action: PolicyAction,
|
||||||
|
pub priority: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PolicyRule {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
name: impl Into<String>,
|
||||||
|
condition: PolicyCondition,
|
||||||
|
action: PolicyAction,
|
||||||
|
priority: u32,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
condition,
|
||||||
|
action,
|
||||||
|
priority,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn matches(&self, context: &LaneContext) -> bool {
|
||||||
|
self.condition.matches(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PolicyCondition {
|
||||||
|
And(Vec<PolicyCondition>),
|
||||||
|
Or(Vec<PolicyCondition>),
|
||||||
|
GreenAt { level: GreenLevel },
|
||||||
|
StaleBranch,
|
||||||
|
StartupBlocked,
|
||||||
|
LaneCompleted,
|
||||||
|
ReviewPassed,
|
||||||
|
ScopedDiff,
|
||||||
|
TimedOut { duration: Duration },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PolicyCondition {
|
||||||
|
#[must_use]
|
||||||
|
pub fn matches(&self, context: &LaneContext) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::And(conditions) => conditions
|
||||||
|
.iter()
|
||||||
|
.all(|condition| condition.matches(context)),
|
||||||
|
Self::Or(conditions) => conditions
|
||||||
|
.iter()
|
||||||
|
.any(|condition| condition.matches(context)),
|
||||||
|
Self::GreenAt { level } => context.green_level >= *level,
|
||||||
|
Self::StaleBranch => context.branch_freshness >= STALE_BRANCH_THRESHOLD,
|
||||||
|
Self::StartupBlocked => context.blocker == LaneBlocker::Startup,
|
||||||
|
Self::LaneCompleted => context.completed,
|
||||||
|
Self::ReviewPassed => context.review_status == ReviewStatus::Approved,
|
||||||
|
Self::ScopedDiff => context.diff_scope == DiffScope::Scoped,
|
||||||
|
Self::TimedOut { duration } => context.branch_freshness >= *duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PolicyAction {
|
||||||
|
MergeToDev,
|
||||||
|
MergeForward,
|
||||||
|
RecoverOnce,
|
||||||
|
Escalate { reason: String },
|
||||||
|
CloseoutLane,
|
||||||
|
CleanupSession,
|
||||||
|
Notify { channel: String },
|
||||||
|
Block { reason: String },
|
||||||
|
Chain(Vec<PolicyAction>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PolicyAction {
|
||||||
|
fn flatten_into(&self, actions: &mut Vec<PolicyAction>) {
|
||||||
|
match self {
|
||||||
|
Self::Chain(chained) => {
|
||||||
|
for action in chained {
|
||||||
|
action.flatten_into(actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => actions.push(self.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LaneBlocker {
|
||||||
|
None,
|
||||||
|
Startup,
|
||||||
|
External,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ReviewStatus {
|
||||||
|
Pending,
|
||||||
|
Approved,
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DiffScope {
|
||||||
|
Full,
|
||||||
|
Scoped,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LaneContext {
|
||||||
|
pub lane_id: String,
|
||||||
|
pub green_level: GreenLevel,
|
||||||
|
pub branch_freshness: Duration,
|
||||||
|
pub blocker: LaneBlocker,
|
||||||
|
pub review_status: ReviewStatus,
|
||||||
|
pub diff_scope: DiffScope,
|
||||||
|
pub completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaneContext {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
lane_id: impl Into<String>,
|
||||||
|
green_level: GreenLevel,
|
||||||
|
branch_freshness: Duration,
|
||||||
|
blocker: LaneBlocker,
|
||||||
|
review_status: ReviewStatus,
|
||||||
|
diff_scope: DiffScope,
|
||||||
|
completed: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
lane_id: lane_id.into(),
|
||||||
|
green_level,
|
||||||
|
branch_freshness,
|
||||||
|
blocker,
|
||||||
|
review_status,
|
||||||
|
diff_scope,
|
||||||
|
completed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PolicyEngine {
|
||||||
|
rules: Vec<PolicyRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PolicyEngine {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(mut rules: Vec<PolicyRule>) -> Self {
|
||||||
|
rules.sort_by_key(|rule| rule.priority);
|
||||||
|
Self { rules }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn rules(&self) -> &[PolicyRule] {
|
||||||
|
&self.rules
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn evaluate(&self, context: &LaneContext) -> Vec<PolicyAction> {
|
||||||
|
evaluate(self, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn evaluate(engine: &PolicyEngine, context: &LaneContext) -> Vec<PolicyAction> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
for rule in &engine.rules {
|
||||||
|
if rule.matches(context) {
|
||||||
|
rule.action.flatten_into(&mut actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
evaluate, DiffScope, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, PolicyEngine,
|
||||||
|
PolicyRule, ReviewStatus, STALE_BRANCH_THRESHOLD,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn default_context() -> LaneContext {
|
||||||
|
LaneContext::new(
|
||||||
|
"lane-7",
|
||||||
|
0,
|
||||||
|
Duration::from_secs(0),
|
||||||
|
LaneBlocker::None,
|
||||||
|
ReviewStatus::Pending,
|
||||||
|
DiffScope::Full,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_to_dev_rule_fires_for_green_scoped_reviewed_lane() {
|
||||||
|
// given
|
||||||
|
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
||||||
|
"merge-to-dev",
|
||||||
|
PolicyCondition::And(vec![
|
||||||
|
PolicyCondition::GreenAt { level: 2 },
|
||||||
|
PolicyCondition::ScopedDiff,
|
||||||
|
PolicyCondition::ReviewPassed,
|
||||||
|
]),
|
||||||
|
PolicyAction::MergeToDev,
|
||||||
|
20,
|
||||||
|
)]);
|
||||||
|
let context = LaneContext::new(
|
||||||
|
"lane-7",
|
||||||
|
3,
|
||||||
|
Duration::from_secs(5),
|
||||||
|
LaneBlocker::None,
|
||||||
|
ReviewStatus::Approved,
|
||||||
|
DiffScope::Scoped,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let actions = engine.evaluate(&context);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(actions, vec![PolicyAction::MergeToDev]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_branch_rule_fires_at_threshold() {
|
||||||
|
// given
|
||||||
|
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
||||||
|
"merge-forward",
|
||||||
|
PolicyCondition::StaleBranch,
|
||||||
|
PolicyAction::MergeForward,
|
||||||
|
10,
|
||||||
|
)]);
|
||||||
|
let context = LaneContext::new(
|
||||||
|
"lane-7",
|
||||||
|
1,
|
||||||
|
STALE_BRANCH_THRESHOLD,
|
||||||
|
LaneBlocker::None,
|
||||||
|
ReviewStatus::Pending,
|
||||||
|
DiffScope::Full,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let actions = engine.evaluate(&context);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(actions, vec![PolicyAction::MergeForward]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_blocked_rule_recovers_then_escalates() {
|
||||||
|
// given
|
||||||
|
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
||||||
|
"startup-recovery",
|
||||||
|
PolicyCondition::StartupBlocked,
|
||||||
|
PolicyAction::Chain(vec![
|
||||||
|
PolicyAction::RecoverOnce,
|
||||||
|
PolicyAction::Escalate {
|
||||||
|
reason: "startup remained blocked".to_string(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
15,
|
||||||
|
)]);
|
||||||
|
let context = LaneContext::new(
|
||||||
|
"lane-7",
|
||||||
|
0,
|
||||||
|
Duration::from_secs(0),
|
||||||
|
LaneBlocker::Startup,
|
||||||
|
ReviewStatus::Pending,
|
||||||
|
DiffScope::Full,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let actions = engine.evaluate(&context);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
actions,
|
||||||
|
vec![
|
||||||
|
PolicyAction::RecoverOnce,
|
||||||
|
PolicyAction::Escalate {
|
||||||
|
reason: "startup remained blocked".to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_lane_rule_closes_out_and_cleans_up() {
|
||||||
|
// given
|
||||||
|
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
||||||
|
"lane-closeout",
|
||||||
|
PolicyCondition::LaneCompleted,
|
||||||
|
PolicyAction::Chain(vec![
|
||||||
|
PolicyAction::CloseoutLane,
|
||||||
|
PolicyAction::CleanupSession,
|
||||||
|
]),
|
||||||
|
30,
|
||||||
|
)]);
|
||||||
|
let context = LaneContext::new(
|
||||||
|
"lane-7",
|
||||||
|
0,
|
||||||
|
Duration::from_secs(0),
|
||||||
|
LaneBlocker::None,
|
||||||
|
ReviewStatus::Pending,
|
||||||
|
DiffScope::Full,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let actions = engine.evaluate(&context);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
actions,
|
||||||
|
vec![PolicyAction::CloseoutLane, PolicyAction::CleanupSession]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_rules_are_returned_in_priority_order_with_stable_ties() {
|
||||||
|
// given
|
||||||
|
let engine = PolicyEngine::new(vec![
|
||||||
|
PolicyRule::new(
|
||||||
|
"late-cleanup",
|
||||||
|
PolicyCondition::And(vec![]),
|
||||||
|
PolicyAction::CleanupSession,
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
PolicyRule::new(
|
||||||
|
"first-notify",
|
||||||
|
PolicyCondition::And(vec![]),
|
||||||
|
PolicyAction::Notify {
|
||||||
|
channel: "ops".to_string(),
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
PolicyRule::new(
|
||||||
|
"second-notify",
|
||||||
|
PolicyCondition::And(vec![]),
|
||||||
|
PolicyAction::Notify {
|
||||||
|
channel: "review".to_string(),
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
PolicyRule::new(
|
||||||
|
"merge",
|
||||||
|
PolicyCondition::And(vec![]),
|
||||||
|
PolicyAction::MergeToDev,
|
||||||
|
20,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let context = default_context();
|
||||||
|
|
||||||
|
// when
|
||||||
|
let actions = evaluate(&engine, &context);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
actions,
|
||||||
|
vec![
|
||||||
|
PolicyAction::Notify {
|
||||||
|
channel: "ops".to_string(),
|
||||||
|
},
|
||||||
|
PolicyAction::Notify {
|
||||||
|
channel: "review".to_string(),
|
||||||
|
},
|
||||||
|
PolicyAction::MergeToDev,
|
||||||
|
PolicyAction::CleanupSession,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combinators_handle_empty_cases_and_nested_chains() {
|
||||||
|
// given
|
||||||
|
let engine = PolicyEngine::new(vec![
|
||||||
|
PolicyRule::new(
|
||||||
|
"empty-and",
|
||||||
|
PolicyCondition::And(vec![]),
|
||||||
|
PolicyAction::Notify {
|
||||||
|
channel: "orchestrator".to_string(),
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
PolicyRule::new(
|
||||||
|
"empty-or",
|
||||||
|
PolicyCondition::Or(vec![]),
|
||||||
|
PolicyAction::Block {
|
||||||
|
reason: "should not fire".to_string(),
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
PolicyRule::new(
|
||||||
|
"nested",
|
||||||
|
PolicyCondition::Or(vec![
|
||||||
|
PolicyCondition::StartupBlocked,
|
||||||
|
PolicyCondition::And(vec![
|
||||||
|
PolicyCondition::GreenAt { level: 2 },
|
||||||
|
PolicyCondition::TimedOut {
|
||||||
|
duration: Duration::from_secs(5),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
PolicyAction::Chain(vec![
|
||||||
|
PolicyAction::Notify {
|
||||||
|
channel: "alerts".to_string(),
|
||||||
|
},
|
||||||
|
PolicyAction::Chain(vec![
|
||||||
|
PolicyAction::MergeForward,
|
||||||
|
PolicyAction::CleanupSession,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
15,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let context = LaneContext::new(
|
||||||
|
"lane-7",
|
||||||
|
2,
|
||||||
|
Duration::from_secs(10),
|
||||||
|
LaneBlocker::External,
|
||||||
|
ReviewStatus::Pending,
|
||||||
|
DiffScope::Full,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let actions = engine.evaluate(&context);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
actions,
|
||||||
|
vec![
|
||||||
|
PolicyAction::Notify {
|
||||||
|
channel: "orchestrator".to_string(),
|
||||||
|
},
|
||||||
|
PolicyAction::Notify {
|
||||||
|
channel: "alerts".to_string(),
|
||||||
|
},
|
||||||
|
PolicyAction::MergeForward,
|
||||||
|
PolicyAction::CleanupSession,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user