From b0d72af6561710289a5daea893cfb3343d98a670 Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 17 Feb 2026 11:38:29 -0700 Subject: [PATCH] Step 595: add approval escalation planner --- editor/CMakeLists.txt | 9 ++ editor/src/ApprovalEscalationPlanner.h | 68 +++++++++++ editor/tests/step595_test.cpp | 149 +++++++++++++++++++++++++ progress.md | 33 ++++++ 4 files changed, 259 insertions(+) create mode 100644 editor/src/ApprovalEscalationPlanner.h create mode 100644 editor/tests/step595_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index d0068e8..106e72e 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -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) diff --git a/editor/src/ApprovalEscalationPlanner.h b/editor/src/ApprovalEscalationPlanner.h new file mode 100644 index 0000000..967cc4f --- /dev/null +++ b/editor/src/ApprovalEscalationPlanner.h @@ -0,0 +1,68 @@ +#pragma once +// Step 595: Approval Escalation Planner + +#include "PolicyGuardrailCatalog.h" + +#include +#include + +enum class EscalationLevel { + None, + Reviewer, + Admin +}; + +struct EscalationPlan { + std::string operation; + GuardrailDecision decision = GuardrailDecision::RequireReview; + EscalationLevel level = EscalationLevel::Reviewer; + bool requiresJustification = true; + std::vector 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; + } +}; diff --git a/editor/tests/step595_test.cpp b/editor/tests/step595_test.cpp new file mode 100644 index 0000000..03c953b --- /dev/null +++ b/editor/tests/step595_test.cpp @@ -0,0 +1,149 @@ +// Step 595: Approval Escalation Planner (12 tests) + +#include "ApprovalEscalationPlanner.h" + +#include + +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; +} diff --git a/progress.md b/progress.md index 6f0dda5..478ff4c 100644 --- a/progress.md +++ b/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`