Step 595: add approval escalation planner
This commit is contained in:
@@ -4234,4 +4234,13 @@ target_link_libraries(step594_test PRIVATE
|
||||
tree_sitter_javascript tree_sitter_typescript
|
||||
tree_sitter_java tree_sitter_rust tree_sitter_go)
|
||||
|
||||
add_executable(step595_test tests/step595_test.cpp)
|
||||
target_include_directories(step595_test PRIVATE src)
|
||||
target_link_libraries(step595_test PRIVATE
|
||||
nlohmann_json::nlohmann_json
|
||||
unofficial::tree-sitter::tree-sitter
|
||||
tree_sitter_python tree_sitter_cpp tree_sitter_elisp
|
||||
tree_sitter_javascript tree_sitter_typescript
|
||||
tree_sitter_java tree_sitter_rust tree_sitter_go)
|
||||
|
||||
# Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies)
|
||||
|
||||
68
editor/src/ApprovalEscalationPlanner.h
Normal file
68
editor/src/ApprovalEscalationPlanner.h
Normal file
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
// Step 595: Approval Escalation Planner
|
||||
|
||||
#include "PolicyGuardrailCatalog.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
enum class EscalationLevel {
|
||||
None,
|
||||
Reviewer,
|
||||
Admin
|
||||
};
|
||||
|
||||
struct EscalationPlan {
|
||||
std::string operation;
|
||||
GuardrailDecision decision = GuardrailDecision::RequireReview;
|
||||
EscalationLevel level = EscalationLevel::Reviewer;
|
||||
bool requiresJustification = true;
|
||||
std::vector<std::string> approverRoles;
|
||||
std::string reason;
|
||||
};
|
||||
|
||||
class ApprovalEscalationPlanner {
|
||||
public:
|
||||
static EscalationPlan planFor(const PolicyGuardrailCatalog& catalog,
|
||||
const std::string& operation) {
|
||||
EscalationPlan plan;
|
||||
plan.operation = operation;
|
||||
const auto decision = catalog.evaluate(operation);
|
||||
plan.decision = decision.decision;
|
||||
plan.reason = decision.reason;
|
||||
|
||||
if (decision.decision == GuardrailDecision::Allow) {
|
||||
plan.level = EscalationLevel::None;
|
||||
plan.requiresJustification = false;
|
||||
plan.approverRoles = {};
|
||||
return plan;
|
||||
}
|
||||
if (decision.decision == GuardrailDecision::Deny) {
|
||||
plan.level = EscalationLevel::Admin;
|
||||
plan.requiresJustification = true;
|
||||
plan.approverRoles = {"security-admin", "platform-owner"};
|
||||
return plan;
|
||||
}
|
||||
|
||||
plan.level = EscalationLevel::Reviewer;
|
||||
plan.requiresJustification = true;
|
||||
plan.approverRoles = {"reviewer"};
|
||||
return plan;
|
||||
}
|
||||
|
||||
static bool isExecutable(const EscalationPlan& plan) {
|
||||
if (plan.decision == GuardrailDecision::Deny) return false;
|
||||
return plan.level == EscalationLevel::None ||
|
||||
plan.level == EscalationLevel::Reviewer;
|
||||
}
|
||||
|
||||
static int riskScore(const EscalationPlan& plan) {
|
||||
int score = 20;
|
||||
if (plan.decision == GuardrailDecision::RequireReview) score += 30;
|
||||
if (plan.decision == GuardrailDecision::Deny) score += 60;
|
||||
if (plan.requiresJustification) score += 10;
|
||||
if (plan.level == EscalationLevel::Admin) score += 10;
|
||||
if (score > 100) score = 100;
|
||||
return score;
|
||||
}
|
||||
};
|
||||
149
editor/tests/step595_test.cpp
Normal file
149
editor/tests/step595_test.cpp
Normal file
@@ -0,0 +1,149 @@
|
||||
// Step 595: Approval Escalation Planner (12 tests)
|
||||
|
||||
#include "ApprovalEscalationPlanner.h"
|
||||
|
||||
#include <iostream>
|
||||
|
||||
static int passed = 0, failed = 0;
|
||||
#define TEST(name) { std::cout << " " << #name << "... "; }
|
||||
#define PASS() { std::cout << "PASS\n"; ++passed; }
|
||||
#define FAIL(msg) { std::cout << "FAIL: " << msg << "\n"; ++failed; }
|
||||
#define CHECK(cond, msg) if (!(cond)) { FAIL(msg); return; } else {}
|
||||
|
||||
static PolicyGuardrailCatalog catalogWithRules() {
|
||||
PolicyGuardrailCatalog c;
|
||||
std::string error;
|
||||
c.addRule({"allow-push", "git push", GuardrailDecision::Allow, "trusted push path"}, &error);
|
||||
c.addRule({"review-deploy", "deploy", GuardrailDecision::RequireReview, "deployment review required"}, &error);
|
||||
c.addRule({"deny-rm", "rm -rf", GuardrailDecision::Deny, "destructive operation denied"}, &error);
|
||||
return c;
|
||||
}
|
||||
|
||||
void test_allow_rule_produces_no_escalation() {
|
||||
TEST(allow_rule_produces_no_escalation);
|
||||
auto c = catalogWithRules();
|
||||
auto plan = ApprovalEscalationPlanner::planFor(c, "git push origin main");
|
||||
CHECK(plan.level == EscalationLevel::None, "level should be none");
|
||||
CHECK(!plan.requiresJustification, "justification should be false");
|
||||
CHECK(plan.approverRoles.empty(), "approvers should be empty");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_review_rule_produces_reviewer_escalation() {
|
||||
TEST(review_rule_produces_reviewer_escalation);
|
||||
auto c = catalogWithRules();
|
||||
auto plan = ApprovalEscalationPlanner::planFor(c, "deploy prod");
|
||||
CHECK(plan.level == EscalationLevel::Reviewer, "level should be reviewer");
|
||||
CHECK(plan.requiresJustification, "justification should be true");
|
||||
CHECK(plan.approverRoles.size() == 1 && plan.approverRoles[0] == "reviewer", "reviewer role expected");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_deny_rule_produces_admin_escalation() {
|
||||
TEST(deny_rule_produces_admin_escalation);
|
||||
auto c = catalogWithRules();
|
||||
auto plan = ApprovalEscalationPlanner::planFor(c, "rm -rf /tmp/x");
|
||||
CHECK(plan.level == EscalationLevel::Admin, "level should be admin");
|
||||
CHECK(plan.approverRoles.size() == 2, "two admin approvers expected");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_default_review_when_no_rule_matches() {
|
||||
TEST(default_review_when_no_rule_matches);
|
||||
auto c = catalogWithRules();
|
||||
auto plan = ApprovalEscalationPlanner::planFor(c, "unknown op");
|
||||
CHECK(plan.decision == GuardrailDecision::RequireReview, "default decision should be review");
|
||||
CHECK(plan.reason == "default_review_required", "default reason mismatch");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_reason_propagates_from_guardrail_decision() {
|
||||
TEST(reason_propagates_from_guardrail_decision);
|
||||
auto c = catalogWithRules();
|
||||
auto plan = ApprovalEscalationPlanner::planFor(c, "deploy prod");
|
||||
CHECK(plan.reason == "deployment review required", "reason should propagate");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_operation_field_is_preserved() {
|
||||
TEST(operation_field_is_preserved);
|
||||
auto c = catalogWithRules();
|
||||
auto plan = ApprovalEscalationPlanner::planFor(c, "git push origin main");
|
||||
CHECK(plan.operation == "git push origin main", "operation field mismatch");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_is_executable_true_for_allow() {
|
||||
TEST(is_executable_true_for_allow);
|
||||
auto c = catalogWithRules();
|
||||
auto plan = ApprovalEscalationPlanner::planFor(c, "git push origin main");
|
||||
CHECK(ApprovalEscalationPlanner::isExecutable(plan), "allow should be executable");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_is_executable_true_for_review() {
|
||||
TEST(is_executable_true_for_review);
|
||||
auto c = catalogWithRules();
|
||||
auto plan = ApprovalEscalationPlanner::planFor(c, "deploy prod");
|
||||
CHECK(ApprovalEscalationPlanner::isExecutable(plan), "review should be executable");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_is_executable_false_for_deny() {
|
||||
TEST(is_executable_false_for_deny);
|
||||
auto c = catalogWithRules();
|
||||
auto plan = ApprovalEscalationPlanner::planFor(c, "rm -rf /tmp/x");
|
||||
CHECK(!ApprovalEscalationPlanner::isExecutable(plan), "deny should not be executable");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_risk_score_increases_by_decision_severity() {
|
||||
TEST(risk_score_increases_by_decision_severity);
|
||||
auto c = catalogWithRules();
|
||||
auto allowPlan = ApprovalEscalationPlanner::planFor(c, "git push origin main");
|
||||
auto reviewPlan = ApprovalEscalationPlanner::planFor(c, "deploy prod");
|
||||
auto denyPlan = ApprovalEscalationPlanner::planFor(c, "rm -rf /tmp/x");
|
||||
CHECK(ApprovalEscalationPlanner::riskScore(allowPlan) < ApprovalEscalationPlanner::riskScore(reviewPlan),
|
||||
"review risk should exceed allow risk");
|
||||
CHECK(ApprovalEscalationPlanner::riskScore(reviewPlan) < ApprovalEscalationPlanner::riskScore(denyPlan),
|
||||
"deny risk should exceed review risk");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_risk_score_capped_at_100() {
|
||||
TEST(risk_score_capped_at_100);
|
||||
EscalationPlan p;
|
||||
p.decision = GuardrailDecision::Deny;
|
||||
p.level = EscalationLevel::Admin;
|
||||
p.requiresJustification = true;
|
||||
CHECK(ApprovalEscalationPlanner::riskScore(p) <= 100, "risk score should be capped");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_admin_plan_requires_justification() {
|
||||
TEST(admin_plan_requires_justification);
|
||||
auto c = catalogWithRules();
|
||||
auto p = ApprovalEscalationPlanner::planFor(c, "rm -rf /tmp/x");
|
||||
CHECK(p.requiresJustification, "admin plan should require justification");
|
||||
PASS();
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::cout << "Step 595: Approval Escalation Planner\n";
|
||||
|
||||
test_allow_rule_produces_no_escalation(); // 1
|
||||
test_review_rule_produces_reviewer_escalation(); // 2
|
||||
test_deny_rule_produces_admin_escalation(); // 3
|
||||
test_default_review_when_no_rule_matches(); // 4
|
||||
test_reason_propagates_from_guardrail_decision(); // 5
|
||||
test_operation_field_is_preserved(); // 6
|
||||
test_is_executable_true_for_allow(); // 7
|
||||
test_is_executable_true_for_review(); // 8
|
||||
test_is_executable_false_for_deny(); // 9
|
||||
test_risk_score_increases_by_decision_severity(); // 10
|
||||
test_risk_score_capped_at_100(); // 11
|
||||
test_admin_plan_requires_justification(); // 12
|
||||
|
||||
std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n";
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
33
progress.md
33
progress.md
@@ -11374,3 +11374,36 @@ allow/review/deny decisions with explainable rule matching.
|
||||
- `editor/src/PolicyGuardrailCatalog.h` within header-size limit (`75` <= `600`)
|
||||
- `editor/tests/step594_test.cpp` within test-file size guidance (`158` lines)
|
||||
- Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md`
|
||||
|
||||
### Step 595: Approval Escalation Planner
|
||||
**Status:** PASS (12/12 tests)
|
||||
|
||||
Implements escalation planning from guardrail decisions, including approver
|
||||
roles, execution eligibility, and normalized risk scoring.
|
||||
|
||||
**Files added:**
|
||||
- `editor/src/ApprovalEscalationPlanner.h` - escalation planner module:
|
||||
- converts allow/review/deny guardrail results into escalation plans
|
||||
- assigns escalation level and approver roles
|
||||
- computes execution eligibility from plan state
|
||||
- computes bounded risk score (`0..100`)
|
||||
- `editor/tests/step595_test.cpp` - 12 tests covering:
|
||||
- allow/review/deny planning behavior
|
||||
- default rule fallback behavior
|
||||
- reason/operation propagation behavior
|
||||
- execution eligibility behavior
|
||||
- risk score ordering and cap behavior
|
||||
|
||||
**Files modified:**
|
||||
- `editor/CMakeLists.txt` - `step595_test` target
|
||||
|
||||
**Verification run:**
|
||||
- `cmake -S editor -B editor/build-native` - PASS
|
||||
- `cmake --build editor/build-native --target step595_test step594_test` - PASS
|
||||
- `./editor/build-native/step595_test` - PASS (12/12)
|
||||
- `./editor/build-native/step594_test` - PASS (12/12) regression coverage
|
||||
|
||||
**Architecture gate check:**
|
||||
- `editor/src/ApprovalEscalationPlanner.h` within header-size limit (`68` <= `600`)
|
||||
- `editor/tests/step595_test.cpp` within test-file size guidance (`149` lines)
|
||||
- Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md`
|
||||
|
||||
Reference in New Issue
Block a user