From 492185c4b130d78b08194ad13033eddf0efe2ab0 Mon Sep 17 00:00:00 2001 From: Michel Osswald Date: Fri, 15 May 2026 11:48:44 +0200 Subject: [PATCH] feat(guard): add deterministic policy engine Adds the private deterministic policy package for Guard launch.\n\nThe package keeps normalized event extraction in risk and moves deterministic policy semantics into internal/guard/policy.\n\nIt does not wire runtime behavior yet; ENG-325 will call this from the Local Policy Provider. --- internal/guard/policy/engine.go | 72 +++++ internal/guard/policy/engine_test.go | 366 ++++++++++++++++++++++++++ internal/guard/policy/pack_default.go | 71 +++++ internal/guard/policy/profiles.go | 35 +++ internal/guard/policy/rule.go | 19 ++ internal/guard/policy/types.go | 104 ++++++++ internal/guard/risk/taxonomy.go | 5 + scripts/guard-e2e-local.sh | 16 ++ 8 files changed, 688 insertions(+) create mode 100644 internal/guard/policy/engine.go create mode 100644 internal/guard/policy/engine_test.go create mode 100644 internal/guard/policy/pack_default.go create mode 100644 internal/guard/policy/profiles.go create mode 100644 internal/guard/policy/rule.go create mode 100644 internal/guard/policy/types.go create mode 100644 internal/guard/risk/taxonomy.go diff --git a/internal/guard/policy/engine.go b/internal/guard/policy/engine.go new file mode 100644 index 0000000..9ce2749 --- /dev/null +++ b/internal/guard/policy/engine.go @@ -0,0 +1,72 @@ +package policy + +import "github.com/kontext-security/kontext-cli/internal/guard/risk" + +type Engine struct { + pack RulePack +} + +func NewEngine(pack RulePack) Engine { + if pack.ID == "" { + pack = DefaultRulePack() + } + return Engine{pack: pack} +} + +func (e Engine) Evaluate(event risk.RiskEvent, cfg Config) Result { + if e.pack.ID == "" { + e.pack = DefaultRulePack() + } + cfg = cfg.withDefaults() + if cfg.RulePack == "" { + cfg.RulePack = e.pack.ID + } + + for _, rule := range e.pack.Rules { + if !categoryEnabled(cfg.Profile, rule.Category) || !rule.When(event) { + continue + } + return Result{ + Decision: DecisionDeny, + Stage: StageDeterministic, + Matched: true, + RuleID: rule.ID, + Category: rule.Category, + Profile: cfg.Profile, + PolicyVersion: cfg.Version, + RulePack: e.pack.ID, + ReasonCode: rule.ReasonCode, + Reason: rule.Reason, + NonBypassable: *cfg.NonBypassableRules && rule.NonBypassable, + MatchedSignals: matchedSignals(event.Signals, rule.MatchedSignals), + } + } + + return Result{ + Decision: DecisionAllow, + Stage: StageDeterministic, + Matched: false, + Profile: cfg.Profile, + PolicyVersion: cfg.Version, + RulePack: e.pack.ID, + ReasonCode: "no_policy_rule_matched", + Reason: "no deterministic policy rule matched", + } +} + +func matchedSignals(eventSignals, ruleSignals []string) []string { + if len(eventSignals) == 0 || len(ruleSignals) == 0 { + return nil + } + allowed := make(map[string]bool, len(ruleSignals)) + for _, signal := range ruleSignals { + allowed[signal] = true + } + matched := make([]string, 0, len(ruleSignals)) + for _, signal := range eventSignals { + if allowed[signal] { + matched = append(matched, signal) + } + } + return matched +} diff --git a/internal/guard/policy/engine_test.go b/internal/guard/policy/engine_test.go new file mode 100644 index 0000000..b9afb1e --- /dev/null +++ b/internal/guard/policy/engine_test.go @@ -0,0 +1,366 @@ +package policy + +import ( + "testing" + + "github.com/kontext-security/kontext-cli/internal/guard/risk" +) + +func TestEngineEvaluateProfileBehavior(t *testing.T) { + tests := []struct { + name string + event risk.RiskEvent + profile Profile + decision Decision + category RuleCategory + reason string + }{ + { + name: "direct infra API with credentials denies in relaxed", + event: risk.RiskEvent{ + Type: risk.EventDirectProviderAPICall, + ProviderCategory: "infrastructure", + CredentialObserved: true, + Signals: []string{"direct_provider_api", "credential_observed"}, + }, + profile: ProfileRelaxed, + decision: DecisionDeny, + category: CategoryDirectInfraAPIWithCredentials, + }, + { + name: "direct infra API with credentials denies in balanced", + event: risk.RiskEvent{ + Type: risk.EventDirectProviderAPICall, + ProviderCategory: "infrastructure", + CredentialObserved: true, + }, + profile: ProfileBalanced, + decision: DecisionDeny, + category: CategoryDirectInfraAPIWithCredentials, + }, + { + name: "direct infra API with credentials denies in strict", + event: risk.RiskEvent{ + Type: risk.EventDirectProviderAPICall, + ProviderCategory: "infrastructure", + CredentialObserved: true, + }, + profile: ProfileStrict, + decision: DecisionDeny, + category: CategoryDirectInfraAPIWithCredentials, + }, + { + name: "destructive persistent resource without explicit intent denies in relaxed", + event: risk.RiskEvent{ + Type: risk.EventDestructiveProviderOperation, + ResourceClass: "database", + Signals: []string{"destructive_verb", "persistent_resource"}, + }, + profile: ProfileRelaxed, + decision: DecisionDeny, + category: CategoryDestructivePersistentResource, + }, + { + name: "destructive persistent resource without explicit intent denies in balanced", + event: risk.RiskEvent{ + Type: risk.EventDestructiveProviderOperation, + ResourceClass: "bucket", + }, + profile: ProfileBalanced, + decision: DecisionDeny, + category: CategoryDestructivePersistentResource, + }, + { + name: "destructive persistent resource without explicit intent denies in strict", + event: risk.RiskEvent{ + Type: risk.EventDestructiveProviderOperation, + ResourceClass: "volume", + }, + profile: ProfileStrict, + decision: DecisionDeny, + category: CategoryDestructivePersistentResource, + }, + { + name: "destructive persistent resource with explicit intent allows", + event: risk.RiskEvent{ + Type: risk.EventDestructiveProviderOperation, + ResourceClass: "database", + ExplicitUserIntent: true, + }, + profile: ProfileBalanced, + decision: DecisionAllow, + }, + { + name: "production mutation allows in relaxed", + event: risk.RiskEvent{ + Environment: "production", + OperationClass: "write", + }, + profile: ProfileRelaxed, + decision: DecisionAllow, + }, + { + name: "production event without operation class allows", + event: risk.RiskEvent{ + Environment: "production", + }, + profile: ProfileBalanced, + decision: DecisionAllow, + }, + { + name: "production mutation denies in balanced", + event: risk.RiskEvent{ + Environment: "production", + OperationClass: "write", + }, + profile: ProfileBalanced, + decision: DecisionDeny, + category: CategoryProductionMutation, + reason: "production mutation blocked by deterministic policy", + }, + { + name: "production mutation denies in strict", + event: risk.RiskEvent{ + Environment: "production", + OperationClass: "delete", + }, + profile: ProfileStrict, + decision: DecisionDeny, + category: CategoryProductionMutation, + reason: "production mutation blocked by deterministic policy", + }, + { + name: "credential access without intent allows in relaxed", + event: risk.RiskEvent{ + Type: risk.EventCredentialAccess, + }, + profile: ProfileRelaxed, + decision: DecisionAllow, + }, + { + name: "credential access without intent denies in balanced", + event: risk.RiskEvent{ + Type: risk.EventCredentialAccess, + Signals: []string{"credential_path"}, + }, + profile: ProfileBalanced, + decision: DecisionDeny, + category: CategoryCredentialAccess, + reason: "credential access blocked by deterministic policy", + }, + { + name: "credential access without intent denies in strict", + event: risk.RiskEvent{ + Type: risk.EventCredentialAccess, + }, + profile: ProfileStrict, + decision: DecisionDeny, + category: CategoryCredentialAccess, + reason: "credential access blocked by deterministic policy", + }, + { + name: "unknown high-risk command allows in relaxed", + event: risk.RiskEvent{ + Type: risk.EventUnknown, + }, + profile: ProfileRelaxed, + decision: DecisionAllow, + }, + { + name: "unknown high-risk command allows in balanced", + event: risk.RiskEvent{ + Type: risk.EventUnknown, + }, + profile: ProfileBalanced, + decision: DecisionAllow, + }, + { + name: "unknown high-risk command denies in strict", + event: risk.RiskEvent{ + Type: risk.EventUnknown, + Signals: []string{"unknown_high_risk"}, + }, + profile: ProfileStrict, + decision: DecisionDeny, + category: CategoryUnknownHighRiskCommand, + reason: "unknown high-risk command blocked by strict deterministic policy", + }, + { + name: "normal event returns allow", + event: risk.RiskEvent{ + Type: risk.EventNormalToolCall, + }, + profile: ProfileBalanced, + decision: DecisionAllow, + }, + } + + engine := NewEngine(DefaultRulePack()) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.Profile = tt.profile + + result := engine.Evaluate(tt.event, cfg) + if result.Decision != tt.decision { + t.Fatalf("decision = %s, want %s", result.Decision, tt.decision) + } + if result.Profile != tt.profile { + t.Fatalf("profile = %s, want %s", result.Profile, tt.profile) + } + if result.Stage != StageDeterministic { + t.Fatalf("stage = %s, want %s", result.Stage, StageDeterministic) + } + if tt.decision == DecisionDeny { + assertDenyMetadata(t, result, tt.category) + if tt.reason != "" && result.Reason != tt.reason { + t.Fatalf("reason = %q, want %q", result.Reason, tt.reason) + } + } + if tt.decision == DecisionAllow && result.Matched { + t.Fatalf("allow result should not be matched: %+v", result) + } + }) + } +} + +func TestDenyResultIncludesMetadata(t *testing.T) { + engine := NewEngine(DefaultRulePack()) + result := engine.Evaluate(risk.RiskEvent{ + Type: risk.EventDirectProviderAPICall, + ProviderCategory: "infrastructure", + CredentialObserved: true, + Signals: []string{"direct_provider_api", "credential_observed"}, + }, DefaultConfig()) + + assertDenyMetadata(t, result, CategoryDirectInfraAPIWithCredentials) + if !result.NonBypassable { + t.Fatal("non-bypassable rule was not marked non-bypassable") + } + if len(result.MatchedSignals) != 2 { + t.Fatalf("matched signals = %+v", result.MatchedSignals) + } +} + +func TestZeroValueConfigUsesDefaultNonBypassableRules(t *testing.T) { + result := NewEngine(RulePack{}).Evaluate(risk.RiskEvent{ + Type: risk.EventDirectProviderAPICall, + ProviderCategory: "infrastructure", + CredentialObserved: true, + }, Config{}) + + if result.Decision != DecisionDeny { + t.Fatalf("decision = %s, want %s", result.Decision, DecisionDeny) + } + if !result.NonBypassable { + t.Fatal("zero-value config should preserve default non-bypassable rules") + } + if result.Profile != ProfileBalanced { + t.Fatalf("profile = %s, want %s", result.Profile, ProfileBalanced) + } + if result.PolicyVersion != DefaultPolicyVersion { + t.Fatalf("policy version = %s, want %s", result.PolicyVersion, DefaultPolicyVersion) + } + if result.RulePack != DefaultRulePackID { + t.Fatalf("rule pack = %s, want %s", result.RulePack, DefaultRulePackID) + } +} + +func TestPartialConfigUsesDefaultNonBypassableRules(t *testing.T) { + result := NewEngine(DefaultRulePack()).Evaluate(risk.RiskEvent{ + Type: risk.EventDirectProviderAPICall, + ProviderCategory: "infrastructure", + CredentialObserved: true, + }, Config{Profile: ProfileBalanced}) + + if result.Decision != DecisionDeny { + t.Fatalf("decision = %s, want %s", result.Decision, DecisionDeny) + } + if !result.NonBypassable { + t.Fatal("partial config should preserve default non-bypassable rules") + } +} + +func TestExplicitNonBypassableOptOutIsHonored(t *testing.T) { + nonBypassableRules := false + result := NewEngine(DefaultRulePack()).Evaluate(risk.RiskEvent{ + Type: risk.EventDirectProviderAPICall, + ProviderCategory: "infrastructure", + CredentialObserved: true, + }, Config{ + Profile: ProfileBalanced, + NonBypassableRules: &nonBypassableRules, + }) + + if result.Decision != DecisionDeny { + t.Fatalf("decision = %s, want %s", result.Decision, DecisionDeny) + } + if result.NonBypassable { + t.Fatal("explicit non-bypassable opt-out should be preserved") + } +} + +func TestUnknownProfileDoesNotRelaxRules(t *testing.T) { + result := NewEngine(DefaultRulePack()).Evaluate(risk.RiskEvent{ + Type: risk.EventUnknown, + }, Config{Profile: "paranoid"}) + + if result.Decision != DecisionDeny { + t.Fatalf("decision = %s, want %s", result.Decision, DecisionDeny) + } + if result.Category != CategoryUnknownHighRiskCommand { + t.Fatalf("category = %s, want %s", result.Category, CategoryUnknownHighRiskCommand) + } +} + +func TestConfigValidate(t *testing.T) { + if err := DefaultConfig().Validate(); err != nil { + t.Fatalf("default config should validate: %v", err) + } + if err := (Config{}).Validate(); err != nil { + t.Fatalf("zero config should validate with defaults: %v", err) + } + + nonBypassableRules := false + if err := (Config{NonBypassableRules: &nonBypassableRules}).Validate(); err != nil { + t.Fatalf("partial config should validate with defaults: %v", err) + } + + cfg := DefaultConfig() + cfg.Profile = "paranoid" + if err := cfg.Validate(); err == nil { + t.Fatal("unknown profile should fail validation") + } + + cfg = DefaultConfig() + cfg.RulePack = "custom" + if err := cfg.Validate(); err == nil { + t.Fatal("unknown rule pack should fail validation") + } +} + +func assertDenyMetadata(t *testing.T, result Result, category RuleCategory) { + t.Helper() + + if !result.Matched { + t.Fatal("deny result was not marked matched") + } + if result.RuleID == "" { + t.Fatal("rule ID is empty") + } + if result.Category != category { + t.Fatalf("category = %s, want %s", result.Category, category) + } + if result.ReasonCode == "" { + t.Fatal("reason code is empty") + } + if result.Reason == "" { + t.Fatal("reason is empty") + } + if result.RulePack == "" { + t.Fatal("rule pack is empty") + } + if result.PolicyVersion == "" { + t.Fatal("policy version is empty") + } +} diff --git a/internal/guard/policy/pack_default.go b/internal/guard/policy/pack_default.go new file mode 100644 index 0000000..337b583 --- /dev/null +++ b/internal/guard/policy/pack_default.go @@ -0,0 +1,71 @@ +package policy + +import "github.com/kontext-security/kontext-cli/internal/guard/risk" + +func DefaultRulePack() RulePack { + return RulePack{ + ID: DefaultRulePackID, + Version: "v1", + Rules: []Rule{ + { + ID: "guard.destructive_persistent_resource.v1", + Category: CategoryDestructivePersistentResource, + ReasonCode: "destructive_operation_without_intent", + Reason: "destructive persistent-resource operation requires explicit user intent", + NonBypassable: true, + MatchedSignals: []string{"destructive_verb", "persistent_resource"}, + When: func(event risk.RiskEvent) bool { + return event.Type == risk.EventDestructiveProviderOperation && + risk.IsPersistentResourceClass(event.ResourceClass) && + !event.ExplicitUserIntent + }, + }, + { + ID: "guard.direct_infra_api_with_credentials.v1", + Category: CategoryDirectInfraAPIWithCredentials, + ReasonCode: "direct_infra_api_with_credential", + Reason: "direct infrastructure API call included credential material", + NonBypassable: true, + MatchedSignals: []string{"direct_provider_api", "credential_observed"}, + When: func(event risk.RiskEvent) bool { + return event.Type == risk.EventDirectProviderAPICall && + event.ProviderCategory == "infrastructure" && + event.CredentialObserved + }, + }, + { + ID: "guard.production_mutation.v1", + Category: CategoryProductionMutation, + ReasonCode: "production_mutation", + Reason: "production mutation blocked by deterministic policy", + MatchedSignals: []string{"production", "mutation"}, + When: func(event risk.RiskEvent) bool { + return event.Environment == "production" && + event.OperationClass != "" && + event.OperationClass != "unknown" && + event.OperationClass != "read" + }, + }, + { + ID: "guard.credential_access.v1", + Category: CategoryCredentialAccess, + ReasonCode: "credential_access_without_intent", + Reason: "credential access blocked by deterministic policy", + MatchedSignals: []string{"credential_path", "shell_credential_access", "credential_observed"}, + When: func(event risk.RiskEvent) bool { + return event.Type == risk.EventCredentialAccess && !event.ExplicitUserIntent + }, + }, + { + ID: "guard.unknown_high_risk.v1", + Category: CategoryUnknownHighRiskCommand, + ReasonCode: "unknown_high_risk_command", + Reason: "unknown high-risk command blocked by strict deterministic policy", + MatchedSignals: []string{"unknown_high_risk"}, + When: func(event risk.RiskEvent) bool { + return event.Type == risk.EventUnknown + }, + }, + }, + } +} diff --git a/internal/guard/policy/profiles.go b/internal/guard/policy/profiles.go new file mode 100644 index 0000000..5a84e76 --- /dev/null +++ b/internal/guard/policy/profiles.go @@ -0,0 +1,35 @@ +package policy + +func enabledCategories(profile Profile) map[RuleCategory]bool { + categories := map[RuleCategory]bool{ + CategoryDirectInfraAPIWithCredentials: true, + CategoryDestructivePersistentResource: true, + } + switch profile { + case ProfileRelaxed: + return categories + case ProfileBalanced: + categories[CategoryProductionMutation] = true + categories[CategoryCredentialAccess] = true + categories[CategorySourceControlWrite] = true + case ProfileStrict: + categories[CategoryProductionMutation] = true + categories[CategoryCredentialAccess] = true + categories[CategorySourceControlWrite] = true + categories[CategoryUnknownHighRiskCommand] = true + categories[CategoryManagedTool] = true + categories[CategoryProviderAPICall] = true + default: + categories[CategoryProductionMutation] = true + categories[CategoryCredentialAccess] = true + categories[CategorySourceControlWrite] = true + categories[CategoryUnknownHighRiskCommand] = true + categories[CategoryManagedTool] = true + categories[CategoryProviderAPICall] = true + } + return categories +} + +func categoryEnabled(profile Profile, category RuleCategory) bool { + return enabledCategories(profile)[category] +} diff --git a/internal/guard/policy/rule.go b/internal/guard/policy/rule.go new file mode 100644 index 0000000..d27e40c --- /dev/null +++ b/internal/guard/policy/rule.go @@ -0,0 +1,19 @@ +package policy + +import "github.com/kontext-security/kontext-cli/internal/guard/risk" + +type Rule struct { + ID string + Category RuleCategory + ReasonCode string + Reason string + NonBypassable bool + MatchedSignals []string + When func(risk.RiskEvent) bool +} + +type RulePack struct { + ID string + Version string + Rules []Rule +} diff --git a/internal/guard/policy/types.go b/internal/guard/policy/types.go new file mode 100644 index 0000000..0d5c888 --- /dev/null +++ b/internal/guard/policy/types.go @@ -0,0 +1,104 @@ +package policy + +import "fmt" + +type Decision string + +const ( + DecisionAllow Decision = "allow" + DecisionDeny Decision = "deny" +) + +type Stage string + +const ( + StageDeterministic Stage = "deterministic" +) + +type Profile string + +const ( + ProfileRelaxed Profile = "relaxed" + ProfileBalanced Profile = "balanced" + ProfileStrict Profile = "strict" +) + +type RuleCategory string + +const ( + CategoryCredentialAccess RuleCategory = "credential_access" + CategoryDirectInfraAPIWithCredentials RuleCategory = "direct_infra_api_with_credentials" + CategoryDestructivePersistentResource RuleCategory = "destructive_persistent_resource" + CategoryProductionMutation RuleCategory = "production_mutation" + CategoryUnknownHighRiskCommand RuleCategory = "unknown_high_risk_command" + CategoryManagedTool RuleCategory = "managed_tool" + CategorySourceControlWrite RuleCategory = "source_control_write" + CategoryProviderAPICall RuleCategory = "provider_api_call" +) + +const ( + DefaultPolicyVersion = "guard-policy-v1" + DefaultRulePackID = "guard-default" +) + +type Config struct { + Version string `json:"version"` + Profile Profile `json:"profile"` + RulePack string `json:"rule_pack"` + NonBypassableRules *bool `json:"non_bypassable_rules,omitempty"` +} + +func DefaultConfig() Config { + nonBypassableRules := true + return Config{ + Version: DefaultPolicyVersion, + Profile: ProfileBalanced, + RulePack: DefaultRulePackID, + NonBypassableRules: &nonBypassableRules, + } +} + +func (c Config) withDefaults() Config { + defaultConfig := DefaultConfig() + if c.Version == "" { + c.Version = defaultConfig.Version + } + if c.Profile == "" { + c.Profile = defaultConfig.Profile + } + if c.RulePack == "" { + c.RulePack = defaultConfig.RulePack + } + if c.NonBypassableRules == nil { + c.NonBypassableRules = defaultConfig.NonBypassableRules + } + return c +} + +func (c Config) Validate() error { + c = c.withDefaults() + switch c.Profile { + case ProfileRelaxed, ProfileBalanced, ProfileStrict: + default: + return fmt.Errorf("unknown policy profile %q", c.Profile) + } + if c.RulePack != DefaultRulePackID { + return fmt.Errorf("unknown rule pack %q", c.RulePack) + } + return nil +} + +type Result struct { + Decision Decision `json:"decision"` + Stage Stage `json:"stage"` + Matched bool `json:"matched"` + RuleID string `json:"rule_id,omitempty"` + Category RuleCategory `json:"category,omitempty"` + Profile Profile `json:"profile"` + PolicyVersion string `json:"policy_version"` + RulePack string `json:"rule_pack"` + ReasonCode string `json:"reason_code"` + Reason string `json:"reason"` + NonBypassable bool `json:"non_bypassable"` + MatchedSignals []string `json:"matched_signals,omitempty"` +} diff --git a/internal/guard/risk/taxonomy.go b/internal/guard/risk/taxonomy.go new file mode 100644 index 0000000..59ad2b4 --- /dev/null +++ b/internal/guard/risk/taxonomy.go @@ -0,0 +1,5 @@ +package risk + +func IsPersistentResourceClass(resource string) bool { + return isPersistentResource(resource) +} diff --git a/scripts/guard-e2e-local.sh b/scripts/guard-e2e-local.sh index e0dc937..38154f0 100755 --- a/scripts/guard-e2e-local.sh +++ b/scripts/guard-e2e-local.sh @@ -122,6 +122,22 @@ assert_telemetry_hook \ "{\"session_id\":\"${SESSION_ID}\",\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git status\"}}" echo "==> checking API summary and persisted events" +for _ in $(seq 1 40); do + if curl -fsS "${BASE_URL}/api/summary" | node -e ' +let raw = ""; +process.stdin.on("data", (chunk) => raw += chunk); +process.stdin.on("end", () => { + const summary = JSON.parse(raw); + if (summary.critical !== 1 || summary.warnings !== 1 || summary.actions !== 4 || summary.sessions !== 1) { + throw new Error(`unexpected summary ${JSON.stringify(summary)}`); + } +}); +' >/dev/null 2>&1; then + break + fi + sleep 0.25 +done + curl -fsS "${BASE_URL}/api/summary" | node -e ' let raw = ""; process.stdin.on("data", (chunk) => raw += chunk);